library(tidyverse)
## Warning: package 'ggplot2' was built under R version 4.5.2
## Warning: package 'dplyr' was built under R version 4.5.2
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.1.4 ✔ readr 2.1.5
## ✔ forcats 1.0.0 ✔ stringr 1.5.2
## ✔ ggplot2 4.0.1 ✔ tibble 3.3.0
## ✔ lubridate 1.9.4 ✔ tidyr 1.3.1
## ✔ purrr 1.1.0
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(caret)
## Warning: package 'caret' was built under R version 4.5.3
## Loading required package: lattice
##
## Attaching package: 'caret'
##
## The following object is masked from 'package:purrr':
##
## lift
library(DT)
## Warning: package 'DT' was built under R version 4.5.3
set.seed(12345)
library(readr)
fund <- read_csv("school/fundraising.csv")
## Rows: 3000 Columns: 21
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (7): zipconvert2, zipconvert3, zipconvert4, zipconvert5, homeowner, fem...
## dbl (14): num_child, income, wealth, home_value, med_fam_inc, avg_fam_inc, p...
##
## ℹ 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.
View(fund)
future_fund <- read_csv("school/future_fundraising.csv")
## Rows: 120 Columns: 20
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (6): zipconvert2, zipconvert3, zipconvert4, zipconvert5, homeowner, female
## dbl (14): num_child, income, wealth, home_value, med_fam_inc, avg_fam_inc, p...
##
## ℹ 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.
View(future_fund)
#response to a factor
fund <- fund %>%
mutate(
target = factor(
target,
levels = c("Donor", "No Donor"),
labels = c("Donor", "No_Donor")
)
)
table(fund$target)
##
## Donor No_Donor
## 1499 1501
prop.table(table(fund$target))
##
## Donor No_Donor
## 0.4996667 0.5003333
round(
prop.table(table(fund$target)) * 100,
2
)
##
## Donor No_Donor
## 49.97 50.03
#split the data to 80/20
set.seed(12345)
train_index <- createDataPartition(
fund$target,
p = 0.80,
list = FALSE
)
fund_train <- fund[train_index, ]
fund_validation <- fund[-train_index, ]
dim(fund_train)
## [1] 2401 21
dim(fund_validation)
## [1] 599 21
table(fund_train$target)
##
## Donor No_Donor
## 1200 1201
table(fund_validation$target)
##
## Donor No_Donor
## 299 300
round(
prop.table(table(fund_train$target)) * 100,
2
)
##
## Donor No_Donor
## 49.98 50.02
round(
prop.table(table(fund_validation$target)) * 100,
2
)
##
## Donor No_Donor
## 49.92 50.08
train_x <- fund_train %>%
select(-target)
train_y <- fund_train$target
validation_x <- fund_validation %>%
select(-target)
validation_y <- fund_validation$target
future_x <- future_fund
cv_control <- trainControl(
method = "cv",
number = 10,
classProbs = TRUE,
summaryFunction = twoClassSummary,
savePredictions = "final"
)
#look at data, check variables and check for any missing values
summary(fund)
## zipconvert2 zipconvert3 zipconvert4 zipconvert5
## Length:3000 Length:3000 Length:3000 Length:3000
## Class :character Class :character Class :character Class :character
## Mode :character Mode :character Mode :character Mode :character
##
##
##
## homeowner num_child income female
## Length:3000 Min. :1.000 Min. :1.000 Length:3000
## Class :character 1st Qu.:1.000 1st Qu.:3.000 Class :character
## Mode :character Median :1.000 Median :4.000 Mode :character
## Mean :1.069 Mean :3.899
## 3rd Qu.:1.000 3rd Qu.:5.000
## Max. :5.000 Max. :7.000
## wealth home_value med_fam_inc avg_fam_inc
## Min. :0.000 Min. : 0.0 Min. : 0.0 Min. : 0.0
## 1st Qu.:5.000 1st Qu.: 554.8 1st Qu.: 278.0 1st Qu.: 318.0
## Median :8.000 Median : 816.5 Median : 355.0 Median : 396.0
## Mean :6.396 Mean :1143.3 Mean : 388.4 Mean : 432.3
## 3rd Qu.:8.000 3rd Qu.:1341.2 3rd Qu.: 465.0 3rd Qu.: 516.0
## Max. :9.000 Max. :5945.0 Max. :1500.0 Max. :1331.0
## pct_lt15k num_prom lifetime_gifts largest_gift
## Min. : 0.00 Min. : 11.00 Min. : 15.0 Min. : 5.00
## 1st Qu.: 5.00 1st Qu.: 29.00 1st Qu.: 45.0 1st Qu.: 10.00
## Median :12.00 Median : 48.00 Median : 81.0 Median : 15.00
## Mean :14.71 Mean : 49.14 Mean : 110.7 Mean : 16.65
## 3rd Qu.:21.00 3rd Qu.: 65.00 3rd Qu.: 135.0 3rd Qu.: 20.00
## Max. :90.00 Max. :157.00 Max. :5674.9 Max. :1000.00
## last_gift months_since_donate time_lag avg_gift
## Min. : 0.00 Min. :17.00 Min. : 0.000 Min. : 2.139
## 1st Qu.: 7.00 1st Qu.:29.00 1st Qu.: 3.000 1st Qu.: 6.333
## Median : 10.00 Median :31.00 Median : 5.000 Median : 9.000
## Mean : 13.48 Mean :31.13 Mean : 6.876 Mean : 10.669
## 3rd Qu.: 16.00 3rd Qu.:34.00 3rd Qu.: 9.000 3rd Qu.: 12.800
## Max. :219.00 Max. :37.00 Max. :77.000 Max. :122.167
## target
## Donor :1499
## No_Donor:1501
##
##
##
##
glimpse(fund)
## Rows: 3,000
## Columns: 21
## $ zipconvert2 <chr> "Yes", "No", "No", "No", "No", "No", "No", "Yes", …
## $ zipconvert3 <chr> "No", "No", "No", "Yes", "Yes", "No", "No", "No", …
## $ zipconvert4 <chr> "No", "No", "No", "No", "No", "No", "Yes", "No", "…
## $ zipconvert5 <chr> "No", "Yes", "Yes", "No", "No", "Yes", "No", "No",…
## $ homeowner <chr> "Yes", "No", "Yes", "Yes", "Yes", "Yes", "Yes", "Y…
## $ num_child <dbl> 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,…
## $ income <dbl> 1, 5, 3, 4, 4, 4, 4, 4, 4, 1, 4, 5, 2, 3, 4, 4, 2,…
## $ female <chr> "No", "Yes", "No", "No", "Yes", "Yes", "No", "Yes"…
## $ wealth <dbl> 7, 8, 4, 8, 8, 8, 5, 8, 8, 5, 5, 8, 8, 5, 6, 9, 7,…
## $ home_value <dbl> 698, 828, 1471, 547, 482, 857, 505, 1438, 1316, 42…
## $ med_fam_inc <dbl> 422, 358, 484, 386, 242, 450, 333, 458, 541, 203, …
## $ avg_fam_inc <dbl> 463, 376, 546, 432, 275, 498, 388, 533, 575, 271, …
## $ pct_lt15k <dbl> 4, 13, 4, 7, 28, 5, 16, 8, 11, 39, 6, 8, 5, 3, 13,…
## $ num_prom <dbl> 46, 32, 94, 20, 38, 47, 51, 21, 66, 73, 59, 25, 27…
## $ lifetime_gifts <dbl> 94, 30, 177, 23, 73, 139, 63, 26, 108, 161, 84, 40…
## $ largest_gift <dbl> 12, 10, 10, 11, 10, 20, 15, 16, 12, 6, 5, 10, 20, …
## $ last_gift <dbl> 12, 5, 8, 11, 10, 20, 10, 16, 7, 3, 3, 10, 20, 7, …
## $ months_since_donate <dbl> 34, 29, 30, 30, 31, 37, 37, 30, 31, 32, 30, 32, 37…
## $ time_lag <dbl> 6, 7, 3, 6, 3, 3, 8, 6, 1, 7, 12, 2, 7, 1, 10, 3, …
## $ avg_gift <dbl> 9.400000, 4.285714, 7.080000, 7.666667, 7.300000, …
## $ target <fct> Donor, Donor, No_Donor, No_Donor, Donor, Donor, Do…
str(fund)
## tibble [3,000 × 21] (S3: tbl_df/tbl/data.frame)
## $ zipconvert2 : chr [1:3000] "Yes" "No" "No" "No" ...
## $ zipconvert3 : chr [1:3000] "No" "No" "No" "Yes" ...
## $ zipconvert4 : chr [1:3000] "No" "No" "No" "No" ...
## $ zipconvert5 : chr [1:3000] "No" "Yes" "Yes" "No" ...
## $ homeowner : chr [1:3000] "Yes" "No" "Yes" "Yes" ...
## $ num_child : num [1:3000] 1 2 1 1 1 1 1 1 1 1 ...
## $ income : num [1:3000] 1 5 3 4 4 4 4 4 4 1 ...
## $ female : chr [1:3000] "No" "Yes" "No" "No" ...
## $ wealth : num [1:3000] 7 8 4 8 8 8 5 8 8 5 ...
## $ home_value : num [1:3000] 698 828 1471 547 482 ...
## $ med_fam_inc : num [1:3000] 422 358 484 386 242 450 333 458 541 203 ...
## $ avg_fam_inc : num [1:3000] 463 376 546 432 275 498 388 533 575 271 ...
## $ pct_lt15k : num [1:3000] 4 13 4 7 28 5 16 8 11 39 ...
## $ num_prom : num [1:3000] 46 32 94 20 38 47 51 21 66 73 ...
## $ lifetime_gifts : num [1:3000] 94 30 177 23 73 139 63 26 108 161 ...
## $ largest_gift : num [1:3000] 12 10 10 11 10 20 15 16 12 6 ...
## $ last_gift : num [1:3000] 12 5 8 11 10 20 10 16 7 3 ...
## $ months_since_donate: num [1:3000] 34 29 30 30 31 37 37 30 31 32 ...
## $ time_lag : num [1:3000] 6 7 3 6 3 3 8 6 1 7 ...
## $ avg_gift : num [1:3000] 9.4 4.29 7.08 7.67 7.3 ...
## $ target : Factor w/ 2 levels "Donor","No_Donor": 1 1 2 2 1 1 1 2 1 1 ...
missing_summary <- sapply(fund, function(x) sum(is.na(x)))
missing_summary
## zipconvert2 zipconvert3 zipconvert4 zipconvert5
## 0 0 0 0
## homeowner num_child income female
## 0 0 0 0
## wealth home_value med_fam_inc avg_fam_inc
## 0 0 0 0
## pct_lt15k num_prom lifetime_gifts largest_gift
## 0 0 0 0
## last_gift months_since_donate time_lag avg_gift
## 0 0 0 0
## target
## 0
table(fund$target)
##
## Donor No_Donor
## 1499 1501
prop.table(table(fund$target))
##
## Donor No_Donor
## 0.4996667 0.5003333
numeric_vars <- fund %>%
select(where(is.numeric))
ncol(numeric_vars)
## [1] 14
numeric_vars %>%
pivot_longer(
everything(),
names_to = "Variable",
values_to = "Value"
) %>%
ggplot(aes(Value)) +
geom_histogram(
bins = 30,
fill = "steelblue",
color = "white"
) +
facet_wrap(~Variable, scales = "free") +
theme_minimal()
fund %>%
pivot_longer(
cols = where(is.numeric),
names_to = "Variable",
values_to = "Value"
) %>%
ggplot(aes(target, Value, fill = target)) +
geom_boxplot(alpha = .8) +
facet_wrap(~Variable, scales = "free") +
theme_minimal() +
theme(
legend.position = "none"
)
cor_matrix <- cor(
numeric_vars,
use = "complete.obs"
)
round(cor_matrix, 2)
## num_child income wealth home_value med_fam_inc avg_fam_inc
## num_child 1.00 0.09 0.06 -0.01 0.05 0.05
## income 0.09 1.00 0.21 0.29 0.37 0.38
## wealth 0.06 0.21 1.00 0.26 0.38 0.39
## home_value -0.01 0.29 0.26 1.00 0.74 0.75
## med_fam_inc 0.05 0.37 0.38 0.74 1.00 0.97
## avg_fam_inc 0.05 0.38 0.39 0.75 0.97 1.00
## pct_lt15k -0.03 -0.28 -0.38 -0.40 -0.67 -0.68
## num_prom -0.09 -0.07 -0.41 -0.06 -0.05 -0.06
## lifetime_gifts -0.05 -0.02 -0.23 -0.02 -0.04 -0.04
## largest_gift -0.02 0.03 -0.03 0.06 0.05 0.04
## last_gift -0.01 0.11 0.05 0.16 0.14 0.13
## months_since_donate -0.01 0.08 0.03 0.02 0.03 0.03
## time_lag -0.01 0.00 -0.07 0.00 0.02 0.02
## avg_gift -0.02 0.12 0.09 0.17 0.14 0.13
## pct_lt15k num_prom lifetime_gifts largest_gift last_gift
## num_child -0.03 -0.09 -0.05 -0.02 -0.01
## income -0.28 -0.07 -0.02 0.03 0.11
## wealth -0.38 -0.41 -0.23 -0.03 0.05
## home_value -0.40 -0.06 -0.02 0.06 0.16
## med_fam_inc -0.67 -0.05 -0.04 0.05 0.14
## avg_fam_inc -0.68 -0.06 -0.04 0.04 0.13
## pct_lt15k 1.00 0.04 0.06 -0.01 -0.06
## num_prom 0.04 1.00 0.54 0.11 -0.06
## lifetime_gifts 0.06 0.54 1.00 0.51 0.20
## largest_gift -0.01 0.11 0.51 1.00 0.45
## last_gift -0.06 -0.06 0.20 0.45 1.00
## months_since_donate -0.01 -0.28 -0.14 0.02 0.19
## time_lag -0.02 0.12 0.04 0.04 0.08
## avg_gift -0.06 -0.15 0.18 0.47 0.87
## months_since_donate time_lag avg_gift
## num_child -0.01 -0.01 -0.02
## income 0.08 0.00 0.12
## wealth 0.03 -0.07 0.09
## home_value 0.02 0.00 0.17
## med_fam_inc 0.03 0.02 0.14
## avg_fam_inc 0.03 0.02 0.13
## pct_lt15k -0.01 -0.02 -0.06
## num_prom -0.28 0.12 -0.15
## lifetime_gifts -0.14 0.04 0.18
## largest_gift 0.02 0.04 0.47
## last_gift 0.19 0.08 0.87
## months_since_donate 1.00 0.02 0.19
## time_lag 0.02 1.00 0.07
## avg_gift 0.19 0.07 1.00
library(corrplot)
## Warning: package 'corrplot' was built under R version 4.5.2
## corrplot 0.95 loaded
corrplot(
cor_matrix,
method = "color",
tl.cex = .7,
number.cex = .6
)
fund %>%
group_by(target) %>%
summarise(
across(
where(is.numeric),
mean,
na.rm = TRUE
)
)
## Warning: There was 1 warning in `summarise()`.
## ℹ In argument: `across(where(is.numeric), mean, na.rm = TRUE)`.
## ℹ In group 1: `target = Donor`.
## Caused by warning:
## ! The `...` argument of `across()` is deprecated as of dplyr 1.1.0.
## Supply arguments directly to `.fns` through an anonymous function instead.
##
## # Previously
## across(a:b, mean, na.rm = TRUE)
##
## # Now
## across(a:b, \(x) mean(x, na.rm = TRUE))
## # A tibble: 2 × 15
## target num_child income wealth home_value med_fam_inc avg_fam_inc pct_lt15k
## <fct> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 Donor 1.05 3.96 6.40 1164. 390. 433. 14.7
## 2 No_Donor 1.08 3.84 6.39 1123. 387. 432. 14.7
## # ℹ 7 more variables: num_prom <dbl>, lifetime_gifts <dbl>, largest_gift <dbl>,
## # last_gift <dbl>, months_since_donate <dbl>, time_lag <dbl>, avg_gift <dbl>
#Overall Distribution of the Predictors The first figure shows the distribution of every numerical predictor.
Several important patterns emerge immediately. Most numerical variables are positively (right) skewed, especially: avg_gift largest_gift last_gift lifetime_gifts home_value
This indicates that most individuals donate relatively small amounts while only a few donors contribute very large gifts. Likewise, neighborhood financial variables such as
home_value avg_fam_inc med_fam_inc
also display long right tails, suggesting variability in socioeconomic status across households. Variables such as: income wealth
are ordinal rather than continuous and therefore naturally appear clustered into categories. The histogram of months_since_donate suggests that many donors have not contributed recently, while num_prom indicates a wide range in the number of promotional mailings previously received.
Mean Comparison
The summary statistics reinforce these visual observations.
Variable Donor No Donor Interpretation Income 3.96 3.84 Donors have slightly higher income levels. Wealth 6.40 6.39 Nearly identical distributions. Home Value 1163.8 1122.7 Donors tend to live in somewhat higher-value neighborhoods. Median Family Income 389.8 387.0 Very small difference. Average Family Income 432.8 431.8 Nearly identical. Promotions Received 50.7 47.6 Donors received more promotions. Lifetime Gifts 113.7 107.8 Donors have contributed more historically. Largest Gift 16.25 17.05 Very similar averages. Last Gift 12.67 14.30 Slight difference between groups.
The differences in historical donation variables are more pronounced than those observed for demographic variables, suggesting that behavioral variables are likely to provide stronger predictive information.
table(fund_train$target, useNA = "ifany")
##
## Donor No_Donor
## 1200 1201
levels(fund_train$target)
## [1] "Donor" "No_Donor"
fund <- fund %>%
mutate(
target = str_trim(as.character(target)),
target = case_when(
target %in% c("Donor", "Yes") ~ "Donor",
target %in% c("No Donor", "No_Donor", "No") ~ "No_Donor",
TRUE ~ NA_character_
),
target = factor(
target,
levels = c("Donor", "No_Donor")
)
)
table(fund$target, useNA = "ifany")
##
## Donor No_Donor
## 1499 1501
set.seed(12345)
train_index <- createDataPartition(
fund$target,
p = 0.80,
list = FALSE
)
fund_train <- fund[train_index, ]
fund_validation <- fund[-train_index, ]
# Identify numeric and categorical predictors from the training set
numeric_names <- fund_train %>%
select(-target) %>%
select(where(is.numeric)) %>%
names()
categorical_names <- fund_train %>%
select(-target) %>%
select(where(is.character)) %>%
names()
numeric_names
## [1] "num_child" "income" "wealth"
## [4] "home_value" "med_fam_inc" "avg_fam_inc"
## [7] "pct_lt15k" "num_prom" "lifetime_gifts"
## [10] "largest_gift" "last_gift" "months_since_donate"
## [13] "time_lag" "avg_gift"
categorical_names
## [1] "zipconvert2" "zipconvert3" "zipconvert4" "zipconvert5" "homeowner"
## [6] "female"
wilcox_results <- lapply(
numeric_names,
function(x) {
test_result <- wilcox.test(
fund_train[[x]] ~ fund_train$target,
exact = FALSE
)
donor_values <- fund_train %>%
filter(target == "Donor") %>%
pull(all_of(x))
non_donor_values <- fund_train %>%
filter(target == "No_Donor") %>%
pull(all_of(x))
data.frame(
Variable = x,
Donor_Mean = mean(
donor_values,
na.rm = TRUE
),
No_Donor_Mean = mean(
non_donor_values,
na.rm = TRUE
),
Donor_Median = median(
donor_values,
na.rm = TRUE
),
No_Donor_Median = median(
non_donor_values,
na.rm = TRUE
),
W_Statistic = unname(
test_result$statistic
),
P_Value = test_result$p.value
)
}
) %>%
bind_rows() %>%
mutate(
Adjusted_P_Value = p.adjust(
P_Value,
method = "BH"
),
Significant = ifelse(
Adjusted_P_Value < 0.05,
"Yes",
"No"
)
) %>%
arrange(Adjusted_P_Value)
wilcox_results
## Variable Donor_Mean No_Donor_Mean Donor_Median No_Donor_Median
## 1 months_since_donate 30.585833 31.797669 31.00000 31.000000
## 2 last_gift 12.750000 14.393838 10.00000 12.000000
## 3 avg_gift 10.191256 11.247593 8.60119 9.818182
## 4 largest_gift 15.757500 17.141965 13.00000 15.000000
## 5 num_prom 50.310833 47.180683 50.00000 44.000000
## 6 income 4.005000 3.832639 4.00000 4.000000
## 7 lifetime_gifts 113.485008 107.401674 82.00000 77.000000
## 8 home_value 1190.540833 1102.825978 835.50000 788.000000
## 9 num_child 1.057500 1.087427 1.00000 1.000000
## 10 time_lag 6.843333 6.877602 5.00000 5.000000
## 11 avg_fam_inc 436.134167 430.510408 397.00000 393.000000
## 12 med_fam_inc 392.718333 385.407161 356.50000 355.000000
## 13 wealth 6.439167 6.417985 8.00000 8.000000
## 14 pct_lt15k 14.686667 14.796003 12.00000 12.000000
## W_Statistic P_Value Adjusted_P_Value Significant
## 1 614190.0 2.765762e-10 3.872067e-09 Yes
## 2 622667.0 6.577368e-09 4.604157e-08 Yes
## 3 631211.5 1.416983e-07 6.612588e-07 Yes
## 4 634174.0 3.064916e-07 1.072721e-06 Yes
## 5 778311.0 6.782392e-04 1.899070e-03 Yes
## 6 763121.5 1.013170e-02 2.364063e-02 Yes
## 7 759282.0 2.275472e-02 4.550945e-02 Yes
## 8 753127.0 5.548638e-02 9.710116e-02 No
## 9 709603.0 8.884078e-02 1.381968e-01 No
## 10 705190.0 3.622153e-01 5.071014e-01 No
## 11 731903.0 5.057599e-01 6.436944e-01 No
## 12 729352.5 6.063507e-01 7.074092e-01 No
## 13 723647.0 8.441291e-01 8.733680e-01 No
## 14 723306.0 8.733680e-01 8.733680e-01 No
wilcox_table <- wilcox_results %>%
mutate(
across(
c(
Donor_Mean,
No_Donor_Mean,
Donor_Median,
No_Donor_Median
),
~ round(.x, 2)
),
P_Value = format.pval(
P_Value,
digits = 3,
eps = 0.001
),
Adjusted_P_Value = format.pval(
Adjusted_P_Value,
digits = 3,
eps = 0.001
)
)
datatable(
wilcox_table,
rownames = FALSE,
options = list(
pageLength = 15,
scrollX = TRUE
)
)
chi_results <- lapply(
categorical_names,
function(x) {
contingency_table <- table(
fund_train[[x]],
fund_train$target
)
test_result <- chisq.test(
contingency_table
)
data.frame(
Variable = x,
Chi_Square = unname(
test_result$statistic
),
Degrees_of_Freedom = unname(
test_result$parameter
),
P_Value = test_result$p.value,
Minimum_Expected_Count = min(
test_result$expected
)
)
}
) %>%
bind_rows() %>%
mutate(
Adjusted_P_Value = p.adjust(
P_Value,
method = "BH"
),
Significant = ifelse(
Adjusted_P_Value < 0.05,
"Yes",
"No"
)
) %>%
arrange(Adjusted_P_Value)
chi_results
## Variable Chi_Square Degrees_of_Freedom P_Value Minimum_Expected_Count
## 1 zipconvert5 3.15451412 1 0.07571723 464.3065
## 2 homeowner 2.68623625 1 0.10121864 275.3853
## 3 zipconvert2 1.07899874 1 0.29892165 253.8942
## 4 zipconvert3 0.45379623 1 0.50053768 221.9075
## 5 female 0.67480740 1 0.41138053 465.3061
## 6 zipconvert4 0.03530836 1 0.85095098 258.3923
## Adjusted_P_Value Significant
## 1 0.3036559 No
## 2 0.3036559 No
## 3 0.5978433 No
## 4 0.6006452 No
## 5 0.6006452 No
## 6 0.8509510 No
chi_table <- chi_results %>%
mutate(
Chi_Square = round(
Chi_Square,
3
),
Minimum_Expected_Count = round(
Minimum_Expected_Count,
2
),
P_Value = format.pval(
P_Value,
digits = 3,
eps = 0.001
),
Adjusted_P_Value = format.pval(
Adjusted_P_Value,
digits = 3,
eps = 0.001
)
)
datatable(
chi_table,
rownames = FALSE,
options = list(
pageLength = 10,
scrollX = TRUE
)
)
categorical_donor_rates <- lapply(
categorical_names,
function(x) {
fund_train %>%
group_by(
Category = .data[[x]]
) %>%
summarise(
Total = n(),
Donors = sum(
target == "Donor",
na.rm = TRUE
),
Donor_Rate = mean(
target == "Donor",
na.rm = TRUE
) * 100,
.groups = "drop"
) %>%
mutate(
Variable = x
) %>%
select(
Variable,
Category,
Total,
Donors,
Donor_Rate
)
}
) %>%
bind_rows() %>%
mutate(
Donor_Rate = round(
Donor_Rate,
2
)
)
categorical_donor_rates
## # A tibble: 12 × 5
## Variable Category Total Donors Donor_Rate
## <chr> <chr> <int> <int> <dbl>
## 1 zipconvert2 No 1893 957 50.6
## 2 zipconvert2 Yes 508 243 47.8
## 3 zipconvert3 No 1957 985 50.3
## 4 zipconvert3 Yes 444 215 48.4
## 5 zipconvert4 No 1884 944 50.1
## 6 zipconvert4 Yes 517 256 49.5
## 7 zipconvert5 No 1472 714 48.5
## 8 zipconvert5 Yes 929 486 52.3
## 9 homeowner No 551 258 46.8
## 10 homeowner Yes 1850 942 50.9
## 11 female No 931 455 48.9
## 12 female Yes 1470 745 50.7
train_x <- fund_train %>%
select(-target)
train_y <- fund_train$target
validation_x <- fund_validation %>%
select(-target)
validation_y <- fund_validation$target
future_x <- future_fund
dummy_encoder <- dummyVars(
~ .,
data = train_x,
fullRank = TRUE
)
train_dummy <- predict(
dummy_encoder,
newdata = train_x
) %>%
as.data.frame()
validation_dummy <- predict(
dummy_encoder,
newdata = validation_x
) %>%
as.data.frame()
future_dummy <- predict(
dummy_encoder,
newdata = future_x
) %>%
as.data.frame()
identical(
names(train_dummy),
names(validation_dummy)
)
## [1] TRUE
identical(
names(train_dummy),
names(future_dummy)
)
## [1] TRUE
preprocess_plan <- preProcess(
train_dummy,
method = c("nzv","medianImpute","center","scale")
)
train_processed_x <- predict(
preprocess_plan,
train_dummy
)
validation_processed_x <- predict(
preprocess_plan,
validation_dummy
)
future_processed_x <- predict(
preprocess_plan,
future_dummy
)
train_processed <- train_processed_x %>%
mutate(
target = train_y
)
dim(train_processed)
## [1] 2401 20
table(train_processed$target)
##
## Donor No_Donor
## 1200 1201
set.seed(12345)
cv_control <- trainControl(
method = "cv",
number = 10,
classProbs = TRUE,
summaryFunction = twoClassSummary,
savePredictions = "final"
)
levels(train_processed$target)
## [1] "Donor" "No_Donor"
set.seed(12345)
fit_logistic <- train(
target ~ .,
data = train_processed,
method = "glm",
family = binomial,
trControl = cv_control,
metric = "ROC"
)
fit_logistic
## Generalized Linear Model
##
## 2401 samples
## 19 predictor
## 2 classes: 'Donor', 'No_Donor'
##
## No pre-processing
## Resampling: Cross-Validated (10 fold)
## Summary of sample sizes: 2160, 2161, 2161, 2161, 2161, 2161, ...
## Resampling results:
##
## ROC Sens Spec
## 0.5828854 0.5491667 0.5637121
library(randomForest)
## Warning: package 'randomForest' was built under R version 4.5.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.seed(12345)
fit_rf <- train(
target ~ .,
data = train_processed,
method = "rf",
trControl = cv_control,
metric = "ROC",
tuneLength = 10,
ntree = 500,
importance = TRUE
)
fit_rf
## Random Forest
##
## 2401 samples
## 19 predictor
## 2 classes: 'Donor', 'No_Donor'
##
## No pre-processing
## Resampling: Cross-Validated (10 fold)
## Summary of sample sizes: 2160, 2161, 2161, 2161, 2161, 2161, ...
## Resampling results across tuning parameters:
##
## mtry ROC Sens Spec
## 2 0.5655621 0.5650000 0.5420937
## 3 0.5636253 0.5425000 0.5395523
## 5 0.5596610 0.5350000 0.5420868
## 7 0.5552302 0.5366667 0.5412466
## 9 0.5495927 0.5216667 0.5345868
## 11 0.5586571 0.5283333 0.5420523
## 13 0.5513439 0.5208333 0.5328857
## 15 0.5496723 0.5108333 0.5378994
## 17 0.5494794 0.5066667 0.5312466
## 19 0.5531028 0.5275000 0.5354132
##
## ROC was used to select the optimal model using the largest value.
## The final value used for the model was mtry = 2.
library(gbm)
## Warning: package 'gbm' was built under R version 4.5.3
## Loaded gbm 2.3.1
## This version of gbm is no longer under development. Consider transitioning to gbm3, https://github.com/gbm-developers/gbm3
gbm_grid <- expand.grid(
interaction.depth = c(1, 3, 5),
n.trees = seq(50, 500, 50),
shrinkage = c(0.01, 0.05, 0.10),
n.minobsinnode = 10
)
set.seed(12345)
fit_gbm <- train(
target ~ .,
data = train_processed,
method = "gbm",
trControl = cv_control,
metric = "ROC",
tuneGrid = gbm_grid,
verbose = FALSE
)
fit_gbm
## Stochastic Gradient Boosting
##
## 2401 samples
## 19 predictor
## 2 classes: 'Donor', 'No_Donor'
##
## No pre-processing
## Resampling: Cross-Validated (10 fold)
## Summary of sample sizes: 2160, 2161, 2161, 2161, 2161, 2161, ...
## Resampling results across tuning parameters:
##
## shrinkage interaction.depth n.trees ROC Sens Spec
## 0.01 1 50 0.5871902 0.5108333 0.5971006
## 0.01 1 100 0.5868709 0.5050000 0.6162190
## 0.01 1 150 0.5866520 0.5091667 0.6112259
## 0.01 1 200 0.5873276 0.4958333 0.6212052
## 0.01 1 250 0.5884808 0.5016667 0.6111915
## 0.01 1 300 0.5867824 0.5000000 0.6036983
## 0.01 1 350 0.5867077 0.5008333 0.6012190
## 0.01 1 400 0.5864314 0.5041667 0.5978994
## 0.01 1 450 0.5869979 0.5050000 0.5970523
## 0.01 1 500 0.5868120 0.5133333 0.5970592
## 0.01 3 50 0.5834890 0.4733333 0.6345041
## 0.01 3 100 0.5834818 0.4991667 0.6137121
## 0.01 3 150 0.5830301 0.5091667 0.6003788
## 0.01 3 200 0.5839170 0.5166667 0.5920386
## 0.01 3 250 0.5828504 0.5250000 0.5911983
## 0.01 3 300 0.5814833 0.5191667 0.5812121
## 0.01 3 350 0.5810635 0.5233333 0.5795730
## 0.01 3 400 0.5799949 0.5275000 0.5770799
## 0.01 3 450 0.5808554 0.5308333 0.5720799
## 0.01 3 500 0.5805433 0.5350000 0.5654270
## 0.01 5 50 0.5807189 0.4916667 0.6178237
## 0.01 5 100 0.5838570 0.5083333 0.5886708
## 0.01 5 150 0.5830572 0.5150000 0.5745730
## 0.01 5 200 0.5840156 0.5250000 0.5753857
## 0.01 5 250 0.5823982 0.5316667 0.5762121
## 0.01 5 300 0.5812022 0.5250000 0.5737190
## 0.01 5 350 0.5797654 0.5325000 0.5612259
## 0.01 5 400 0.5793595 0.5333333 0.5712397
## 0.01 5 450 0.5784559 0.5383333 0.5703994
## 0.01 5 500 0.5772937 0.5408333 0.5620799
## 0.05 1 50 0.5801494 0.4900000 0.5970799
## 0.05 1 100 0.5821347 0.5041667 0.5995386
## 0.05 1 150 0.5808439 0.5158333 0.5945523
## 0.05 1 200 0.5778736 0.5225000 0.5837466
## 0.05 1 250 0.5776246 0.5216667 0.5837190
## 0.05 1 300 0.5780453 0.5341667 0.5820592
## 0.05 1 350 0.5763645 0.5333333 0.5820592
## 0.05 1 400 0.5755462 0.5250000 0.5845455
## 0.05 1 450 0.5750011 0.5250000 0.5820799
## 0.05 1 500 0.5751161 0.5308333 0.5703857
## 0.05 3 50 0.5785560 0.5175000 0.5886846
## 0.05 3 100 0.5743572 0.5333333 0.5695317
## 0.05 3 150 0.5731283 0.5400000 0.5703926
## 0.05 3 200 0.5717544 0.5316667 0.5687121
## 0.05 3 250 0.5719180 0.5391667 0.5737121
## 0.05 3 300 0.5684960 0.5466667 0.5587397
## 0.05 3 350 0.5675515 0.5466667 0.5604132
## 0.05 3 400 0.5660358 0.5491667 0.5604201
## 0.05 3 450 0.5656340 0.5525000 0.5520868
## 0.05 3 500 0.5637859 0.5375000 0.5570868
## 0.05 5 50 0.5776976 0.5283333 0.5696006
## 0.05 5 100 0.5777009 0.5391667 0.5695592
## 0.05 5 150 0.5707661 0.5475000 0.5629063
## 0.05 5 200 0.5635941 0.5408333 0.5512259
## 0.05 5 250 0.5596989 0.5383333 0.5395455
## 0.05 5 300 0.5578395 0.5375000 0.5428788
## 0.05 5 350 0.5554613 0.5308333 0.5478857
## 0.05 5 400 0.5523394 0.5350000 0.5453719
## 0.05 5 450 0.5503411 0.5275000 0.5428926
## 0.05 5 500 0.5484089 0.5358333 0.5453857
## 0.10 1 50 0.5830633 0.4991667 0.6162121
## 0.10 1 100 0.5824015 0.5166667 0.6012397
## 0.10 1 150 0.5796074 0.5258333 0.5878650
## 0.10 1 200 0.5757002 0.5383333 0.5703926
## 0.10 1 250 0.5763155 0.5216667 0.5653719
## 0.10 1 300 0.5713349 0.5250000 0.5637603
## 0.10 1 350 0.5746566 0.5216667 0.5670661
## 0.10 1 400 0.5677965 0.5108333 0.5578857
## 0.10 1 450 0.5656247 0.5141667 0.5645455
## 0.10 1 500 0.5636862 0.5150000 0.5570730
## 0.10 3 50 0.5754660 0.5350000 0.5762190
## 0.10 3 100 0.5650912 0.5558333 0.5570730
## 0.10 3 150 0.5592927 0.5491667 0.5362741
## 0.10 3 200 0.5514911 0.5308333 0.5279477
## 0.10 3 250 0.5479465 0.5308333 0.5320455
## 0.10 3 300 0.5482414 0.5325000 0.5470592
## 0.10 3 350 0.5467438 0.5350000 0.5329339
## 0.10 3 400 0.5435460 0.5358333 0.5287466
## 0.10 3 450 0.5393327 0.5358333 0.5287534
## 0.10 3 500 0.5378383 0.5366667 0.5237603
## 0.10 5 50 0.5695336 0.5358333 0.5504408
## 0.10 5 100 0.5629578 0.5341667 0.5571212
## 0.10 5 150 0.5559458 0.5266667 0.5404339
## 0.10 5 200 0.5525657 0.5250000 0.5304683
## 0.10 5 250 0.5460956 0.5208333 0.5196419
## 0.10 5 300 0.5395363 0.5283333 0.5196556
## 0.10 5 350 0.5368030 0.5283333 0.5154752
## 0.10 5 400 0.5323716 0.5216667 0.5254477
## 0.10 5 450 0.5338858 0.5183333 0.5196143
## 0.10 5 500 0.5309390 0.5316667 0.5096143
##
## Tuning parameter 'n.minobsinnode' was held constant at a value of 10
## ROC was used to select the optimal model using the largest value.
## The final values used for the model were n.trees = 250, interaction.depth =
## 1, shrinkage = 0.01 and n.minobsinnode = 10.
model_resamples <- resamples(
list(
Logistic = fit_logistic,
RandomForest = fit_rf,
GradientBoosting = fit_gbm
)
)
summary(model_resamples)
##
## Call:
## summary.resamples(object = model_resamples)
##
## Models: Logistic, RandomForest, GradientBoosting
## Number of resamples: 10
##
## ROC
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## Logistic 0.5203472 0.5558422 0.5835069 0.5828854 0.6100174 0.6450694
## RandomForest 0.5031250 0.5492420 0.5668750 0.5655621 0.5946007 0.6138194
## GradientBoosting 0.5389583 0.5540574 0.5905556 0.5884808 0.6060243 0.6670139
## NA's
## Logistic 0
## RandomForest 0
## GradientBoosting 0
##
## Sens
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## Logistic 0.4833333 0.5104167 0.5416667 0.5491667 0.5854167 0.6333333
## RandomForest 0.4416667 0.5583333 0.5708333 0.5650000 0.6041667 0.6166667
## GradientBoosting 0.4250000 0.4583333 0.5000000 0.5016667 0.5562500 0.5583333
## NA's
## Logistic 0
## RandomForest 0
## GradientBoosting 0
##
## Spec
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## Logistic 0.4916667 0.5363636 0.5708333 0.5637121 0.5979167 0.6083333
## RandomForest 0.4876033 0.5000000 0.5458333 0.5420937 0.5666667 0.6250000
## GradientBoosting 0.5250000 0.5756026 0.6208333 0.6111915 0.6416667 0.7250000
## NA's
## Logistic 0
## RandomForest 0
## GradientBoosting 0
bwplot(
model_resamples,
metric = "ROC",
main = "Cross-Validated ROC Comparison"
)
dotplot(
model_resamples,
metric = "ROC",
main = "Model Performance Comparison"
)
Logistic regression is balanced and nearly tied for the best ROC. Random Forest has the highest donor sensitivity. Gradient Boosting has the highest ROC and specificity. All ROC values are fairly close to 0.50, so predictive separation is modest.
library(pROC)
## Warning: package 'pROC' was built under R version 4.5.3
## Type 'citation("pROC")' for a citation.
##
## Attaching package: 'pROC'
## The following objects are masked from 'package:stats':
##
## cov, smooth, var
logistic_class <- predict(
fit_logistic,
newdata = validation_processed_x,
type = "raw"
)
logistic_prob <- predict(
fit_logistic,
newdata = validation_processed_x,
type = "prob"
)
rf_class <- predict(
fit_rf,
newdata = validation_processed_x,
type = "raw"
)
rf_prob <- predict(
fit_rf,
newdata = validation_processed_x,
type = "prob"
)
gbm_class <- predict(
fit_gbm,
newdata = validation_processed_x,
type = "raw"
)
gbm_prob <- predict(
fit_gbm,
newdata = validation_processed_x,
type = "prob"
)
library(caret)
logistic_cm <- confusionMatrix(
logistic_class,
validation_y,
positive = "Donor"
)
rf_cm <- confusionMatrix(
rf_class,
validation_y,
positive = "Donor"
)
gbm_cm <- confusionMatrix(
gbm_class,
validation_y,
positive = "Donor"
)
logistic_cm
## Confusion Matrix and Statistics
##
## Reference
## Prediction Donor No_Donor
## Donor 177 147
## No_Donor 122 153
##
## Accuracy : 0.5509
## 95% CI : (0.5101, 0.5912)
## No Information Rate : 0.5008
## P-Value [Acc > NIR] : 0.007924
##
## Kappa : 0.102
##
## Mcnemar's Test P-Value : 0.143384
##
## Sensitivity : 0.5920
## Specificity : 0.5100
## Pos Pred Value : 0.5463
## Neg Pred Value : 0.5564
## Prevalence : 0.4992
## Detection Rate : 0.2955
## Detection Prevalence : 0.5409
## Balanced Accuracy : 0.5510
##
## 'Positive' Class : Donor
##
rf_cm
## Confusion Matrix and Statistics
##
## Reference
## Prediction Donor No_Donor
## Donor 161 148
## No_Donor 138 152
##
## Accuracy : 0.5225
## 95% CI : (0.4817, 0.5632)
## No Information Rate : 0.5008
## P-Value [Acc > NIR] : 0.1535
##
## Kappa : 0.0451
##
## Mcnemar's Test P-Value : 0.5946
##
## Sensitivity : 0.5385
## Specificity : 0.5067
## Pos Pred Value : 0.5210
## Neg Pred Value : 0.5241
## Prevalence : 0.4992
## Detection Rate : 0.2688
## Detection Prevalence : 0.5159
## Balanced Accuracy : 0.5226
##
## 'Positive' Class : Donor
##
gbm_cm
## Confusion Matrix and Statistics
##
## Reference
## Prediction Donor No_Donor
## Donor 162 124
## No_Donor 137 176
##
## Accuracy : 0.5643
## 95% CI : (0.5235, 0.6044)
## No Information Rate : 0.5008
## P-Value [Acc > NIR] : 0.001077
##
## Kappa : 0.1285
##
## Mcnemar's Test P-Value : 0.457614
##
## Sensitivity : 0.5418
## Specificity : 0.5867
## Pos Pred Value : 0.5664
## Neg Pred Value : 0.5623
## Prevalence : 0.4992
## Detection Rate : 0.2705
## Detection Prevalence : 0.4775
## Balanced Accuracy : 0.5642
##
## 'Positive' Class : Donor
##
logistic_roc <- roc(
response = validation_y,
predictor = logistic_prob$Donor,
levels = c("No_Donor", "Donor"),
direction = "<"
)
rf_roc <- roc(
response = validation_y,
predictor = rf_prob$Donor,
levels = c("No_Donor", "Donor"),
direction = "<"
)
gbm_roc <- roc(
response = validation_y,
predictor = gbm_prob$Donor,
levels = c("No_Donor", "Donor"),
direction = "<"
)
auc(logistic_roc)
## Area under the curve: 0.5457
auc(rf_roc)
## Area under the curve: 0.5152
auc(gbm_roc)
## Area under the curve: 0.569
plot(
logistic_roc,
col = "steelblue",
lwd = 3,
main = "Validation ROC Curves"
)
plot(
rf_roc,
col = "firebrick",
lwd = 3,
add = TRUE
)
plot(
gbm_roc,
col = "darkgreen",
lwd = 3,
add = TRUE
)
abline(
0, 1,
lty = 2,
col = "gray50"
)
legend(
"bottomright",
legend = c(
paste0("Logistic (AUC = ", round(auc(logistic_roc), 3), ")"),
paste0("Random Forest (AUC = ", round(auc(rf_roc), 3), ")"),
paste0("Gradient Boosting (AUC = ", round(auc(gbm_roc), 3), ")")
),
col = c(
"steelblue",
"firebrick",
"darkgreen"
),
lwd = 3,
bty = "n"
)
Logistic Regression Highest donor sensitivity (59.2%) Good baseline
model Easy to interpret Validation ROC decreased from 0.583 to 0.546
It identifies the largest proportion of donors but misclassifies more non-donors.
Random Forest
Random Forest was honestly a little disappointing.
Lowest validation ROC (0.515) Lowest accuracy (52.4%) Lowest Kappa (0.049)
Despite its flexibility, it did not generalize well to unseen data.
Gradient Boosting
Gradient Boosting consistently performed best overall.
Highest validation ROC (0.573) Highest validation accuracy (56.1%) Highest specificity (55.0%) Highest Kappa (0.122)
Although its sensitivity is slightly lower than logistic regression, it provides a much better balance between identifying donors and avoiding false positives.
For a fundraising campaign, mailing unnecessary solicitations is costly, so improving specificity has real business value.
Going based off the results even though logistic regression captures a few more donors, gradient boosting provides the strongest overall descrimination between donors and no donors, due to having the highest scores other than sensitivity
#Get Validation Probabilities
gbm_prob <- predict(
fit_gbm,
newdata = validation_processed_x,
type = "prob"
)
head(gbm_prob)
## Donor No_Donor
## 1 0.5098856 0.4901144
## 2 0.5954272 0.4045728
## 3 0.4979468 0.5020532
## 4 0.4167457 0.5832543
## 5 0.5035020 0.4964980
## 6 0.4200376 0.5799624
mail_cost <- 0.68
donation <- 13
cutoffs <- seq(0.10, 0.90, by = 0.01)
profit_results <- map_df(cutoffs, function(cut){
prediction <- ifelse(
gbm_prob$Donor >= cut,
"Donor",
"No_Donor"
)
mailed <- prediction == "Donor"
actual_donor <- validation_y == "Donor"
total_mailings <- sum(mailed)
donations <- sum(mailed & actual_donor)
revenue <- donations * donation
mailing_cost <- total_mailings * mail_cost
profit <- revenue - mailing_cost
tibble(
Cutoff = cut,
Mailings = total_mailings,
Donations = donations,
Revenue = revenue,
Mailing_Cost = mailing_cost,
Profit = profit
)
})
profit_results
## # A tibble: 81 × 6
## Cutoff Mailings Donations Revenue Mailing_Cost Profit
## <dbl> <int> <int> <dbl> <dbl> <dbl>
## 1 0.1 599 299 3887 407. 3480.
## 2 0.11 599 299 3887 407. 3480.
## 3 0.12 599 299 3887 407. 3480.
## 4 0.13 599 299 3887 407. 3480.
## 5 0.14 599 299 3887 407. 3480.
## 6 0.15 599 299 3887 407. 3480.
## 7 0.16 599 299 3887 407. 3480.
## 8 0.17 599 299 3887 407. 3480.
## 9 0.18 599 299 3887 407. 3480.
## 10 0.19 599 299 3887 407. 3480.
## # ℹ 71 more rows
best_cutoff <- profit_results %>%
slice_max(
Profit,
n = 1,
with_ties = FALSE
)
best_cutoff
## # A tibble: 1 × 6
## Cutoff Mailings Donations Revenue Mailing_Cost Profit
## <dbl> <int> <int> <dbl> <dbl> <dbl>
## 1 0.4 598 299 3887 407. 3480.
mail_percent <- best_cutoff$Mailings /
nrow(validation_processed_x)
round(
mail_percent * 100,
2
)
## [1] 99.83
best_cutoff %>%
mutate(
Percent_Mailed = round(
100 * Mailings /
nrow(validation_processed_x),
2
)
)
## # A tibble: 1 × 7
## Cutoff Mailings Donations Revenue Mailing_Cost Profit Percent_Mailed
## <dbl> <int> <int> <dbl> <dbl> <dbl> <dbl>
## 1 0.4 598 299 3887 407. 3480. 99.8
profit_results <- profit_results %>%
mutate(
Profit_Per_Mailing = Profit / Mailings
)
profit_results
## # A tibble: 81 × 7
## Cutoff Mailings Donations Revenue Mailing_Cost Profit Profit_Per_Mailing
## <dbl> <int> <int> <dbl> <dbl> <dbl> <dbl>
## 1 0.1 599 299 3887 407. 3480. 5.81
## 2 0.11 599 299 3887 407. 3480. 5.81
## 3 0.12 599 299 3887 407. 3480. 5.81
## 4 0.13 599 299 3887 407. 3480. 5.81
## 5 0.14 599 299 3887 407. 3480. 5.81
## 6 0.15 599 299 3887 407. 3480. 5.81
## 7 0.16 599 299 3887 407. 3480. 5.81
## 8 0.17 599 299 3887 407. 3480. 5.81
## 9 0.18 599 299 3887 407. 3480. 5.81
## 10 0.19 599 299 3887 407. 3480. 5.81
## # ℹ 71 more rows
profit_results %>%
distinct(Mailings, .keep_all = TRUE)
## # A tibble: 31 × 7
## Cutoff Mailings Donations Revenue Mailing_Cost Profit Profit_Per_Mailing
## <dbl> <int> <int> <dbl> <dbl> <dbl> <dbl>
## 1 0.1 599 299 3887 407. 3480. 5.81
## 2 0.4 598 299 3887 407. 3480. 5.82
## 3 0.42 580 289 3757 394. 3363. 5.80
## 4 0.43 540 273 3549 367. 3182. 5.89
## 5 0.44 521 266 3458 354. 3104. 5.96
## 6 0.45 485 252 3276 330. 2946. 6.07
## 7 0.46 466 242 3146 317. 2829. 6.07
## 8 0.47 443 232 3016 301. 2715. 6.13
## 9 0.48 400 219 2847 272 2575 6.44
## 10 0.49 344 192 2496 234. 2262. 6.58
## # ℹ 21 more rows
profit_plot <- profit_results %>%
distinct(Mailings, .keep_all = TRUE)
ggplot(profit_plot,
aes(Cutoff, Profit)) +
geom_line(
color = "steelblue",
linewidth = 1.2
) +
geom_point(
color = "firebrick",
size = 2
) +
labs(
title = "Expected Profit by Probability Cutoff",
x = "Probability Cutoff",
y = "Expected Profit ($)"
) +
theme_minimal()
training and validation data are balanced (roughly 50% donors and 50%
non-donors), whereas the real campaign has only about a 5.1% response
rate.
Because of this balanced sample:
The model predicts many individuals with probabilities near 0.50. Almost everyone exceeds the very low break-even probability implied by the mailing cost (0.68) relative to the average donation ($13.00). As a result, the profit analysis on the balanced validation set suggests mailing nearly everyone.
This is an important limitation to mention.
importance <- varImp(fit_gbm)
plot(importance,
top = 15,
main = "Gradient Boosting Variable Importance")
#NOW LETS SEE WHAT I GET
future_prob <- predict(
fit_gbm,
newdata = future_processed_x,
type = "prob"
)
head(future_prob)
## Donor No_Donor
## 1 0.5814565 0.4185435
## 2 0.4648134 0.5351866
## 3 0.5591237 0.4408763
## 4 0.4247319 0.5752681
## 5 0.6103071 0.3896929
## 6 0.4183518 0.5816482
optimal_cutoff <- best_cutoff$Cutoff
optimal_cutoff
## [1] 0.4
future_predictions <- future_prob %>%
mutate(
value = ifelse(
Donor >= optimal_cutoff,
"Donor",
"No Donor"
)
) %>%
select(value)
View(future_predictions)
dim(future_predictions)
## [1] 120 1
table(future_predictions$value)
##
## Donor
## 120
round(
prop.table(table(future_predictions$value)) * 100,
2
)
##
## Donor
## 100
summary(future_prob$Donor)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.4139 0.4440 0.4836 0.4989 0.5379 0.6903
range(future_prob$Donor)
## [1] 0.4138722 0.6903397
future_predictions <- future_prob %>%
mutate(
value = ifelse(
Donor >= 0.50,
"Donor",
"No Donor"
)
) %>%
select(value)
dim(future_predictions)
## [1] 120 1
table(future_predictions$value)
##
## Donor No Donor
## 47 73
round(
prop.table(table(future_predictions$value)) * 100,
2
)
##
## Donor No Donor
## 39.17 60.83
View(future_predictions)
write_csv(
future_predictions,
"future_fundraising_predictions.csv"
)
submission_check <- read_csv(
"future_fundraising_predictions.csv"
)
## Rows: 120 Columns: 1
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (1): value
##
## ℹ 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.
dim(submission_check)
## [1] 120 1
names(submission_check)
## [1] "value"
table(submission_check$value)
##
## Donor No Donor
## 47 73
head(submission_check)
## # A tibble: 6 × 1
## value
## <chr>
## 1 Donor
## 2 No Donor
## 3 Donor
## 4 No Donor
## 5 Donor
## 6 No Donor
The selected Gradient Boosting model was applied to the 120 future fundraising candidates. Using a classification cutoff of 0.50, the model predicted 47 candidates (39.17%) as donors and 73 candidates (60.83%) as non-donors.