An auto insurer wants two things out of this book of business: (1)
the probability that a policyholder crashes their car
(TARGET_FLAG), and (2) if they do crash, how much that
claim costs (TARGET_AMT). I build binary logistic
regression models for the first question and multiple linear regression
models for the second, using the ~8,000-record training panel and a
held-out evaluation set of 1,633 policies.
I want to flag one thing up front: TARGET_AMT is zero
for every policy that didn’t crash and strictly positive (and heavily
right-skewed) for the ones that did. That single fact drives a lot of
the modeling choices below, so I come back to it repeatedly rather than
pretending it isn’t there.
train_path <- "C:/Users/dbely/OneDrive/Desktop/Econometrics/Assignment 2/insurance-training-data2-2.csv"
test_path <- "C:/Users/dbely/OneDrive/Desktop/Econometrics/Assignment 2/insurance-testing-data2-2.csv"
data_files <- c(train_path, test_path)
missing_files <- data_files[!file.exists(data_files)]
if (length(missing_files) > 0) {
stop("Missing data file(s): ", paste(missing_files, collapse = ", "),
". Double-check these paths and filenames and re-knit.")
}
train_raw <- read.csv(train_path, stringsAsFactors = FALSE)
test_raw <- read.csv(test_path, stringsAsFactors = FALSE)
dim(train_raw)
## [1] 6528 26
dim(test_raw)
## [1] 1633 26
The training file has 6528 records and 26 columns (INDEX
plus the two response variables plus 23 candidate predictors). The
evaluation file has 1633 records in the same layout.
str(train_raw)
## 'data.frame': 6528 obs. of 26 variables:
## $ INDEX : int 8023 4658 2005 4396 3706 3871 8670 833 5896 7432 ...
## $ TARGET_FLAG: int 0 0 0 0 0 0 0 1 0 0 ...
## $ TARGET_AMT : num 0 0 0 0 0 ...
## $ KIDSDRIV : int 0 0 0 0 0 2 0 0 0 0 ...
## $ AGE : int 40 48 51 56 51 45 65 56 53 49 ...
## $ HOMEKIDS : int 0 0 0 0 0 2 0 0 1 0 ...
## $ YOJ : int 9 0 14 10 9 11 15 13 10 12 ...
## $ INCOME : chr "$78,658" "$0" "$89,606" "$199,336" ...
## $ PARENT1 : chr "No" "No" "No" "No" ...
## $ HOME_VAL : chr "$236,005" "$75,688" "$288,266" "$501,076" ...
## $ MSTATUS : chr "z_No" "Yes" "z_No" "Yes" ...
## $ SEX : chr "z_F" "z_F" "z_F" "z_F" ...
## $ EDUCATION : chr "Masters" "Masters" "Bachelors" "PhD" ...
## $ JOB : chr "Lawyer" "Home Maker" "Clerical" "" ...
## $ TRAVTIME : int 69 39 32 23 46 14 39 48 51 26 ...
## $ CAR_USE : chr "Private" "Private" "Private" "Commercial" ...
## $ BLUEBOOK : chr "$17,040" "$1,500" "$10,460" "$41,280" ...
## $ TIF : int 4 6 1 1 6 3 4 1 10 1 ...
## $ CAR_TYPE : chr "Pickup" "Sports Car" "z_SUV" "Van" ...
## $ RED_CAR : chr "no" "no" "no" "no" ...
## $ OLDCLAIM : chr "$0" "$3,867" "$21,581" "$0" ...
## $ CLM_FREQ : int 0 3 2 0 0 2 0 1 0 0 ...
## $ REVOKED : chr "No" "No" "Yes" "No" ...
## $ MVR_PTS : int 1 5 0 1 0 3 1 7 0 0 ...
## $ CAR_AGE : int 15 NA 8 16 14 7 11 15 NA 10 ...
## $ URBANICITY : chr "z_Highly Rural/ Rural" "Highly Urban/ Urban"
## "Highly Urban/ Urban" "Highly Urban/ Urban" ...
Several columns that are obviously numeric (INCOME,
HOME_VAL, BLUEBOOK, OLDCLAIM)
were read in as text because the raw values are formatted like
"$78,658". I deal with that in Data Preparation. For now I
look at the columns in their raw form to document what’s actually in the
file.
miss_tbl <- sapply(train_raw, function(x) sum(is.na(x) | x == ""))
miss_tbl <- miss_tbl[miss_tbl > 0]
kable(data.frame(Variable = names(miss_tbl), Missing = miss_tbl,
Pct = round(100 * miss_tbl / nrow(train_raw), 1)),
row.names = FALSE, caption = "Missing values in the training data")
| Variable | Missing | Pct |
|---|---|---|
| AGE | 3 | 0.0 |
| YOJ | 375 | 5.7 |
| INCOME | 354 | 5.4 |
| HOME_VAL | 368 | 5.6 |
| JOB | 419 | 6.4 |
| CAR_AGE | 399 | 6.1 |
Six variables have missing values: AGE (barely, 3
records), and then YOJ, INCOME,
HOME_VAL, JOB, and CAR_AGE each
missing somewhere between 5% and 6% of records. Nothing is missing more
than about 6% of the time, which is a relief, since I don’t have to make
any heroic imputation assumptions. CAR_AGE also has at
least one clearly impossible value (a negative vehicle age), which I
treat as a data-entry error and recode to missing before imputing
it.
parse_money <- function(x) as.numeric(gsub("[$,]", "", x))
num_preview <- train_raw %>%
transmute(TARGET_FLAG, TARGET_AMT, AGE, YOJ,
INCOME = parse_money(INCOME),
HOME_VAL = parse_money(HOME_VAL),
BLUEBOOK = parse_money(BLUEBOOK),
OLDCLAIM = parse_money(OLDCLAIM),
TRAVTIME, TIF, CLM_FREQ, MVR_PTS, CAR_AGE, KIDSDRIV, HOMEKIDS)
desc <- psych::describe(num_preview)[, c("n","mean","sd","median","min","max","skew")]
kable(round(desc, 1), caption = "Summary statistics for key numeric variables")
| n | mean | sd | median | min | max | skew | |
|---|---|---|---|---|---|---|---|
| TARGET_FLAG | 6528 | 0.3 | 0.4 | 0 | 0 | 1.0 | 1.1 |
| TARGET_AMT | 6528 | 1466.6 | 4545.7 | 0 | 0 | 107586.1 | 9.1 |
| AGE | 6525 | 44.9 | 8.7 | 45 | 16 | 81.0 | 0.0 |
| YOJ | 6153 | 10.5 | 4.1 | 11 | 0 | 23.0 | -1.2 |
| INCOME | 6174 | 61649.3 | 47658.4 | 53276 | 0 | 367030.0 | 1.2 |
| HOME_VAL | 6160 | 154298.4 | 128874.6 | 160680 | 0 | 885282.0 | 0.5 |
| BLUEBOOK | 6528 | 15641.8 | 8381.5 | 14370 | 1500 | 69740.0 | 0.8 |
| OLDCLAIM | 6528 | 4119.3 | 8924.7 | 0 | 0 | 57037.0 | 3.1 |
| TRAVTIME | 6528 | 33.4 | 16.0 | 33 | 5 | 142.0 | 0.5 |
| TIF | 6528 | 5.4 | 4.1 | 4 | 1 | 25.0 | 0.9 |
| CLM_FREQ | 6528 | 0.8 | 1.2 | 0 | 0 | 5.0 | 1.2 |
| MVR_PTS | 6528 | 1.7 | 2.1 | 1 | 0 | 13.0 | 1.3 |
| CAR_AGE | 6129 | 8.3 | 5.7 | 8 | -3 | 27.0 | 0.3 |
| KIDSDRIV | 6528 | 0.2 | 0.5 | 0 | 0 | 4.0 | 3.3 |
| HOMEKIDS | 6528 | 0.7 | 1.1 | 0 | 0 | 5.0 | 1.3 |
A few things jump out:
TARGET_AMT has a skew of roughly 9:
almost all the mass sits at zero (no crash), with a long right tail of
expensive claims. Any linear model on this variable in its raw form is
going to fight the skew.MVR_PTS,
CLM_FREQ, and
KIDSDRIV are also right-skewed count
variables, which is expected: most drivers have zero moving violations,
zero recent claims, and zero teenage drivers.INCOME and
HOME_VAL both have a meaningful mass of
exact zeros (renters with HOME_VAL = 0, and some records
with INCOME = 0), which is a legitimate value here, not
obviously a missing-data placeholder.The numeric summary above doesn’t cover the 10 categorical predictors, so I build a matching table for them here: level counts, share of the sample, and – as a preview of what’s coming in the correlation and modeling sections – the raw crash rate within each level.
clean_disp <- function(x) str_remove(str_trim(x), "^z_")
cat_vars <- c("PARENT1","MSTATUS","SEX","EDUCATION","JOB","CAR_USE",
"CAR_TYPE","RED_CAR","REVOKED","URBANICITY")
cat_summary <- lapply(cat_vars, function(v) {
train_raw %>%
mutate(level = clean_disp(.data[[v]]), level = ifelse(is.na(level) | level == "", "(Missing)", level)) %>%
group_by(level) %>%
summarise(N = n(), Pct = round(100 * n() / nrow(train_raw), 1),
CrashRate = round(100 * mean(TARGET_FLAG), 1), .groups = "drop") %>%
mutate(Variable = v, .before = 1)
}) %>% bind_rows()
kable(cat_summary, row.names = FALSE,
caption = "Summary statistics for categorical variables: level, count, share of sample, and raw crash rate")
| Variable | level | N | Pct | CrashRate |
|---|---|---|---|---|
| PARENT1 | No | 5672 | 86.9 | 23.7 |
| PARENT1 | Yes | 856 | 13.1 | 44.3 |
| MSTATUS | No | 2608 | 40.0 | 33.4 |
| MSTATUS | Yes | 3920 | 60.0 | 21.7 |
| SEX | F | 3493 | 53.5 | 27.2 |
| SEX | M | 3035 | 46.5 | 25.4 |
| EDUCATION | <High School | 973 | 14.9 | 32.0 |
| EDUCATION | Bachelors | 1781 | 27.3 | 23.0 |
| EDUCATION | High School | 1860 | 28.5 | 33.8 |
| EDUCATION | Masters | 1331 | 20.4 | 20.4 |
| EDUCATION | PhD | 583 | 8.9 | 17.2 |
| JOB | (Missing) | 419 | 6.4 | 26.0 |
| JOB | Blue Collar | 1452 | 22.2 | 35.2 |
| JOB | Clerical | 1054 | 16.1 | 28.4 |
| JOB | Doctor | 190 | 2.9 | 8.9 |
| JOB | Home Maker | 512 | 7.8 | 28.1 |
| JOB | Lawyer | 688 | 10.5 | 19.0 |
| JOB | Manager | 758 | 11.6 | 14.2 |
| JOB | Professional | 887 | 13.6 | 22.0 |
| JOB | Student | 568 | 8.7 | 36.6 |
| CAR_USE | Commercial | 2408 | 36.9 | 34.5 |
| CAR_USE | Private | 4120 | 63.1 | 21.6 |
| CAR_TYPE | Minivan | 1751 | 26.8 | 16.3 |
| CAR_TYPE | Panel Truck | 526 | 8.1 | 25.9 |
| CAR_TYPE | Pickup | 1106 | 16.9 | 32.3 |
| CAR_TYPE | SUV | 1832 | 28.1 | 30.2 |
| CAR_TYPE | Sports Car | 715 | 11.0 | 32.6 |
| CAR_TYPE | Van | 598 | 9.2 | 26.1 |
| RED_CAR | no | 4620 | 70.8 | 26.6 |
| RED_CAR | yes | 1908 | 29.2 | 25.7 |
| REVOKED | No | 5698 | 87.3 | 23.7 |
| REVOKED | Yes | 830 | 12.7 | 44.5 |
| URBANICITY | Highly Rural/ Rural | 1346 | 20.6 | 6.9 |
| URBANICITY | Highly Urban/ Urban | 5182 | 79.4 | 31.4 |
A few takeaways that carry through to the models later:
URBANICITY has by far the widest spread in crash rate
across its two levels, single parents (PARENT1) and
unmarried policyholders (MSTATUS) both show elevated crash
rates, and JOB categories split fairly cleanly into “white
collar” (Doctor, Lawyer, Manager, lower crash rate) versus everyone
else, which lines up with the theoretical prior on the assignment
sheet.
ggplot(train_raw %>% filter(TARGET_AMT > 0), aes(x = TARGET_AMT)) +
geom_histogram(bins = 40, fill = "steelblue", color = "white") +
scale_x_continuous(labels = scales::comma) +
labs(title = "Claim amount, conditional on a crash", x = "TARGET_AMT ($)", y = "Count") +
theme_minimal()
Distribution of claim amount among crashes
ggplot(train_raw %>% filter(TARGET_AMT > 0), aes(x = log(TARGET_AMT))) +
geom_histogram(bins = 40, fill = "darkorange", color = "white") +
labs(title = "log(TARGET_AMT), conditional on a crash", x = "log(TARGET_AMT)", y = "Count") +
theme_minimal()
Log-claim amount among crashes
The log transform pulls the claim-amount distribution much closer to
symmetric, which is the main justification for modeling
log(TARGET_AMT) rather than the raw dollar figure later
on.
ggplot(train_raw, aes(x = factor(TARGET_FLAG), y = MVR_PTS)) +
geom_boxplot(fill = c("seagreen","firebrick"), alpha = 0.6) +
labs(title = "MVR points vs. crash outcome", x = "TARGET_FLAG (1 = crash)", y = "MVR_PTS") +
theme_minimal()
Motor vehicle record points by crash status
train_raw %>%
group_by(URBANICITY) %>%
summarise(crash_rate = mean(TARGET_FLAG), n = n()) %>%
ggplot(aes(x = URBANICITY, y = crash_rate)) +
geom_col(fill = "slateblue") +
labs(title = "Crash rate by home/work area", y = "Crash rate", x = NULL) +
theme_minimal()
Crash rate by urbanicity
Urban policyholders crash far more often than rural ones, and this turns out to be one of the strongest single predictors in the whole data set, well beyond what I expected going in.
corr_all <- cor(num_preview, use = "complete.obs")
corrplot(corr_all, method = "color", type = "upper", tl.cex = 0.7, tl.col = "black",
addCoef.col = "black", number.cex = 0.55, diag = FALSE,
title = "Correlation matrix: targets and numeric predictors", mar = c(0,0,2,0))
Correlation heatmap of numeric variables
corr_tbl <- data.frame(
Variable = rownames(corr_all),
With_TARGET_FLAG = round(corr_all[, "TARGET_FLAG"], 3),
With_TARGET_AMT = round(corr_all[, "TARGET_AMT"], 3)
) %>% filter(!Variable %in% c("TARGET_FLAG","TARGET_AMT")) %>%
arrange(With_TARGET_FLAG)
rownames(corr_tbl) <- NULL
kable(corr_tbl, caption = "Correlation of numeric predictors with each target variable")
| Variable | With_TARGET_FLAG | With_TARGET_AMT |
|---|---|---|
| HOME_VAL | -0.168 | -0.083 |
| INCOME | -0.131 | -0.052 |
| AGE | -0.113 | -0.052 |
| BLUEBOOK | -0.108 | 0.001 |
| CAR_AGE | -0.103 | -0.056 |
| TIF | -0.078 | -0.036 |
| YOJ | -0.067 | -0.028 |
| TRAVTIME | 0.044 | 0.027 |
| KIDSDRIV | 0.110 | 0.057 |
| HOMEKIDS | 0.119 | 0.066 |
| OLDCLAIM | 0.144 | 0.081 |
| CLM_FREQ | 0.222 | 0.130 |
| MVR_PTS | 0.227 | 0.146 |
Reading the table: MVR_PTS and CLM_FREQ
(past behavior) correlate positively with crashing again, which is
exactly the Becker-style “past record predicts future record” logic the
assignment sheet flags. HOME_VAL, INCOME, and
vehicle AGE/BLUEBOOK all correlate negatively:
wealthier, older-car households crash less, consistent with the
theoretical priors in the assignment. The same variables correlate with
TARGET_AMT in the same direction but much more weakly,
which foreshadows a finding I come back to later: these variables
predict whether a crash happens far better than they predict
how expensive it is. Off the diagonal, the strongest
predictor-predictor correlation is between HOME_VAL and
INCOME (wealthier households own more expensive homes),
which is worth keeping in mind for multicollinearity even though, as
shown later, it doesn’t turn out to inflate any variance inflation
factors past standard thresholds.
I write a single prep_data() function and apply it
identically to the training and evaluation sets, so whatever I do to one
happens to the other. Imputation values (medians, modal job) are learned
on the training set only and then reused on the evaluation set, to avoid
leaking evaluation-set information into the model.
parse_money <- function(x) as.numeric(gsub("[$,]", "", x))
clean_base <- function(df) {
df <- df %>%
mutate(
INCOME = parse_money(INCOME),
HOME_VAL = parse_money(HOME_VAL),
BLUEBOOK = parse_money(BLUEBOOK),
OLDCLAIM = parse_money(OLDCLAIM),
PARENT1 = str_trim(PARENT1),
MSTATUS = str_remove(str_trim(MSTATUS), "^z_"),
SEX = str_remove(str_trim(SEX), "^z_"),
EDUCATION = str_remove(str_trim(EDUCATION), "^z_"),
EDUCATION = ifelse(EDUCATION == "<High School", "Under High School", EDUCATION),
JOB = str_remove(str_trim(JOB), "^z_"),
JOB = na_if(JOB, ""),
CAR_USE = str_trim(CAR_USE),
CAR_TYPE = str_remove(str_trim(CAR_TYPE), "^z_"),
RED_CAR = str_trim(RED_CAR),
REVOKED = str_trim(REVOKED),
URBANICITY = str_remove(str_trim(URBANICITY), "^z_"),
URBANICITY = case_when(
URBANICITY == "Highly Rural/ Rural" ~ "Rural",
URBANICITY == "Highly Urban/ Urban" ~ "Urban",
TRUE ~ URBANICITY)
)
# a negative vehicle age is a data-entry error, not a real value
df$CAR_AGE[df$CAR_AGE < 0 & !is.na(df$CAR_AGE)] <- NA
df
}
train_c <- clean_base(train_raw)
test_c <- clean_base(test_raw)
Per the assignment suggestions, I create a “was this missing” flag before imputing, so the model can pick up any signal in missingness itself, separate from whatever value I plug in.
miss_vars <- c("AGE","YOJ","INCOME","HOME_VAL","CAR_AGE","JOB")
add_miss_flags <- function(df) {
for (v in miss_vars) {
df[[paste0("M_", v)]] <- as.integer(is.na(df[[v]]))
}
df
}
train_c <- add_miss_flags(train_c)
test_c <- add_miss_flags(test_c)
# learn imputation values on TRAIN only
med_age <- median(train_c$AGE, na.rm = TRUE)
med_yoj <- median(train_c$YOJ, na.rm = TRUE)
med_income <- median(train_c$INCOME, na.rm = TRUE)
med_homeval <- median(train_c$HOME_VAL, na.rm = TRUE)
med_carage <- median(train_c$CAR_AGE, na.rm = TRUE)
mode_job <- names(sort(table(train_c$JOB), decreasing = TRUE))[1]
impute_all <- function(df) {
df$AGE[is.na(df$AGE)] <- med_age
df$YOJ[is.na(df$YOJ)] <- med_yoj
df$INCOME[is.na(df$INCOME)] <- med_income
df$HOME_VAL[is.na(df$HOME_VAL)] <- med_homeval
df$CAR_AGE[is.na(df$CAR_AGE)] <- med_carage
df$JOB[is.na(df$JOB)] <- mode_job
df
}
train_c <- impute_all(train_c)
test_c <- impute_all(test_c)
cat("Imputed with -> AGE:", med_age, " YOJ:", med_yoj, "\n",
" INCOME:", med_income, " HOME_VAL:", med_homeval, "\n",
" CAR_AGE:", med_carage, " JOB:", mode_job, "\n")
## Imputed with -> AGE: 45 YOJ: 11
## INCOME: 53276 HOME_VAL: 160680
## CAR_AGE: 8 JOB: Blue Collar
Note on M_AGE: only 3 training records are missing
AGE. That’s too few for a stable coefficient on the flag
(it produces a huge standard error / quasi-separation if included), so I
impute AGE but drop M_AGE from the candidate
variable list in modeling below.
engineer <- function(df) {
df %>%
mutate(
HOME_OWNER = as.integer(HOME_VAL > 0),
HAS_TEEN_DRIVER = as.integer(KIDSDRIV > 0),
LOG_INCOME = log1p(INCOME),
LOG_HOME_VAL = log1p(HOME_VAL),
LOG_BLUEBOOK = log(BLUEBOOK),
LOG_OLDCLAIM = log1p(OLDCLAIM),
LOG_TARGET_AMT = log1p(TARGET_AMT)
)
}
train_c <- engineer(train_c)
test_c <- engineer(test_c)
HOME_OWNER collapses the very right-skewed
HOME_VAL into a simple owns-a-home flag, on top of keeping
the continuous log-transformed value.HAS_TEEN_DRIVER mirrors KIDSDRIV, letting
a model use whichever form (continuous count vs. any-teen-driver-at-all)
fits better.LOG_INCOME, LOG_HOME_VAL,
LOG_BLUEBOOK, and LOG_OLDCLAIM address the
skew documented above; log1p (i.e. log(1+x))
handles the variables that can legitimately be zero, while
BLUEBOOK (never zero) gets a plain log.Rather than hand-building dummy columns, I let R’s
factor/glm/lm machinery do it, but I
explicitly set the reference level for each factor so the coefficients
have an intuitive baseline (e.g., “Blue Collar” worker, “Minivan”
driver, non-urban).
set_factors <- function(df) {
df$JOB <- relevel(factor(df$JOB), ref = "Blue Collar")
df$EDUCATION <- relevel(factor(df$EDUCATION), ref = "Under High School")
df$CAR_TYPE <- relevel(factor(df$CAR_TYPE), ref = "Minivan")
df$PARENT1 <- relevel(factor(df$PARENT1), ref = "No")
df$MSTATUS <- relevel(factor(df$MSTATUS), ref = "No")
df$SEX <- relevel(factor(df$SEX), ref = "F")
df$RED_CAR <- relevel(factor(df$RED_CAR), ref = "no")
df$REVOKED <- relevel(factor(df$REVOKED), ref = "No")
df$CAR_USE <- relevel(factor(df$CAR_USE), ref = "Private")
df$URBANICITY <- relevel(factor(df$URBANICITY), ref = "Rural")
df
}
train_p <- set_factors(train_c)
test_p <- set_factors(test_c)
sapply(train_p[c("JOB","EDUCATION","CAR_TYPE","URBANICITY")], levels)
## $JOB
## [1] "Blue Collar" "Clerical" "Doctor" "Home Maker"
## [5] "Lawyer" "Manager" "Professional" "Student"
##
## $EDUCATION
## [1] "Under High School" "Bachelors" "High School"
## [4] "Masters" "PhD"
##
## $CAR_TYPE
## [1] "Minivan" "Panel Truck" "Pickup" "Sports Car"
## [5] "SUV" "Van"
##
## $URBANICITY
## [1] "Rural" "Urban"
Final prepared training set: 6528 rows, 39 columns.
Re-running the same summary statistics from Data Exploration confirms
the missing-value and skewness fixes actually landed: every variable
below now shows the full 6528 observations (no more NAs
pulling n down), and the log-transformed dollar variables
have visibly lower skew than their raw versions.
post_vars <- train_p %>%
transmute(AGE, YOJ, INCOME, LOG_INCOME, HOME_VAL, LOG_HOME_VAL,
BLUEBOOK, LOG_BLUEBOOK, OLDCLAIM, LOG_OLDCLAIM, CAR_AGE)
post_desc <- psych::describe(post_vars)[, c("n","mean","sd","median","min","max","skew")]
kable(round(post_desc, 1), caption = "Post-cleaning summary statistics (imputed + transformed)")
| n | mean | sd | median | min | max | skew | |
|---|---|---|---|---|---|---|---|
| AGE | 6528 | 44.9 | 8.6 | 45.0 | 16.0 | 81.0 | 0.0 |
| YOJ | 6528 | 10.5 | 4.0 | 11.0 | 0.0 | 23.0 | -1.3 |
| INCOME | 6528 | 61195.3 | 46386.8 | 53276.0 | 0.0 | 367030.0 | 1.3 |
| LOG_INCOME | 6528 | 10.0 | 3.0 | 10.9 | 0.0 | 12.8 | -2.8 |
| HOME_VAL | 6528 | 154658.2 | 125197.5 | 160680.0 | 0.0 | 885282.0 | 0.5 |
| LOG_HOME_VAL | 6528 | 8.8 | 5.5 | 12.0 | 0.0 | 13.7 | -1.0 |
| BLUEBOOK | 6528 | 15641.8 | 8381.5 | 14370.0 | 1500.0 | 69740.0 | 0.8 |
| LOG_BLUEBOOK | 6528 | 9.5 | 0.6 | 9.6 | 7.3 | 11.2 | -0.9 |
| OLDCLAIM | 6528 | 4119.3 | 8924.7 | 0.0 | 0.0 | 57037.0 | 3.1 |
| LOG_OLDCLAIM | 6528 | 3.4 | 4.3 | 0.0 | 0.0 | 11.0 | 0.5 |
| CAR_AGE | 6528 | 8.3 | 5.5 | 8.0 | 0.0 | 27.0 | 0.3 |
INCOME skew drops from roughly 1.2 to well under 1 on
the log scale, and HOME_VAL and OLDCLAIM show
the same pattern, so the log transforms are doing what they’re supposed
to.
I build three binary logistic regression models for
TARGET_FLAG and two multiple linear regression models (plus
one supplementary severity model) for TARGET_AMT, then add
a bonus LASSO/Ridge/probit check on top of the logistic side.
Every candidate predictor, untransformed, so I can see the whole field before narrowing anything down.
logit1 <- glm(TARGET_FLAG ~ KIDSDRIV + AGE + HOMEKIDS + YOJ + INCOME + HOME_VAL +
TRAVTIME + BLUEBOOK + TIF + OLDCLAIM + CLM_FREQ + MVR_PTS + CAR_AGE +
PARENT1 + MSTATUS + SEX + RED_CAR + REVOKED + URBANICITY + CAR_USE +
EDUCATION + JOB + CAR_TYPE + M_YOJ + M_INCOME + M_HOME_VAL + M_CAR_AGE + M_JOB,
data = train_p, family = binomial)
summary(logit1)
##
## Call:
## glm(formula = TARGET_FLAG ~ KIDSDRIV + AGE + HOMEKIDS + YOJ +
## INCOME + HOME_VAL + TRAVTIME + BLUEBOOK + TIF + OLDCLAIM +
## CLM_FREQ + MVR_PTS + CAR_AGE + PARENT1 + MSTATUS + SEX +
## RED_CAR + REVOKED + URBANICITY + CAR_USE + EDUCATION + JOB +
## CAR_TYPE + M_YOJ + M_INCOME + M_HOME_VAL + M_CAR_AGE + M_JOB,
## family = binomial, data = train_p)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -3.297e+00 3.141e-01 -10.495 < 2e-16 ***
## KIDSDRIV 4.452e-01 6.939e-02 6.416 1.40e-10 ***
## AGE -1.717e-03 4.516e-03 -0.380 0.703737
## HOMEKIDS 4.371e-02 4.227e-02 1.034 0.301144
## YOJ -1.424e-02 9.588e-03 -1.485 0.137522
## INCOME -3.146e-06 1.207e-06 -2.606 0.009149 **
## HOME_VAL -1.092e-06 3.835e-07 -2.848 0.004397 **
## TRAVTIME 1.345e-02 2.103e-03 6.396 1.60e-10 ***
## BLUEBOOK -1.999e-05 5.905e-06 -3.386 0.000710 ***
## TIF -5.648e-02 8.234e-03 -6.859 6.91e-12 ***
## OLDCLAIM -1.535e-05 4.287e-06 -3.580 0.000343 ***
## CLM_FREQ 2.046e-01 3.180e-02 6.434 1.24e-10 ***
## MVR_PTS 1.158e-01 1.525e-02 7.592 3.16e-14 ***
## CAR_AGE 3.995e-03 8.433e-03 0.474 0.635691
## PARENT1Yes 3.981e-01 1.230e-01 3.236 0.001213 **
## MSTATUSYes -4.988e-01 9.393e-02 -5.311 1.09e-07 ***
## SEXM 1.571e-01 1.253e-01 1.254 0.209832
## RED_CARyes -1.374e-02 9.648e-02 -0.142 0.886721
## REVOKEDYes 9.378e-01 1.003e-01 9.351 < 2e-16 ***
## URBANICITYUrban 2.349e+00 1.251e-01 18.775 < 2e-16 ***
## CAR_USECommercial 7.540e-01 1.036e-01 7.277 3.42e-13 ***
## EDUCATIONBachelors -4.585e-01 1.290e-01 -3.554 0.000380 ***
## EDUCATIONHigh School -1.250e-02 1.062e-01 -0.118 0.906263
## EDUCATIONMasters -3.022e-01 1.990e-01 -1.518 0.128937
## EDUCATIONPhD -1.706e-01 2.363e-01 -0.722 0.470313
## JOBClerical 3.291e-02 1.196e-01 0.275 0.783261
## JOBDoctor -1.198e+00 3.448e-01 -3.473 0.000515 ***
## JOBHome Maker -1.265e-01 1.718e-01 -0.737 0.461415
## JOBLawyer -3.029e-01 2.081e-01 -1.455 0.145614
## JOBManager -8.963e-01 1.588e-01 -5.645 1.65e-08 ***
## JOBProfessional -1.971e-01 1.353e-01 -1.456 0.145281
## JOBStudent -1.259e-01 1.444e-01 -0.872 0.383313
## CAR_TYPEPanel Truck 4.441e-01 1.822e-01 2.437 0.014801 *
## CAR_TYPEPickup 5.561e-01 1.120e-01 4.963 6.94e-07 ***
## CAR_TYPESports Car 1.029e+00 1.460e-01 7.048 1.82e-12 ***
## CAR_TYPESUV 8.337e-01 1.246e-01 6.693 2.18e-11 ***
## CAR_TYPEVan 5.528e-01 1.417e-01 3.902 9.53e-05 ***
## M_YOJ 1.325e-01 1.381e-01 0.959 0.337574
## M_INCOME -8.301e-02 1.465e-01 -0.567 0.570928
## M_HOME_VAL 1.367e-02 1.390e-01 0.098 0.921654
## M_CAR_AGE 1.250e-01 1.344e-01 0.930 0.352591
## M_JOB -3.594e-01 2.082e-01 -1.726 0.084306 .
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 7533.1 on 6527 degrees of freedom
## Residual deviance: 5821.4 on 6486 degrees of freedom
## AIC: 5905.4
##
## Number of Fisher Scoring iterations: 5
cat("AIC:", AIC(logit1), " LogLik:", as.numeric(logLik(logit1)), "\n")
## AIC: 5905.352 LogLik: -2910.676
Starting from Model 1, I let MASS::stepAIC remove
variables one at a time as long as doing so improves AIC. This is the
“let the algorithm narrow it down” model, contrasted with Model 3’s
manual, theory-first approach below.
logit2 <- stepAIC(logit1, direction = "backward", trace = FALSE)
summary(logit2)
##
## Call:
## glm(formula = TARGET_FLAG ~ KIDSDRIV + INCOME + HOME_VAL + TRAVTIME +
## BLUEBOOK + TIF + OLDCLAIM + CLM_FREQ + MVR_PTS + PARENT1 +
## MSTATUS + REVOKED + URBANICITY + CAR_USE + EDUCATION + JOB +
## CAR_TYPE + M_JOB, family = binomial, data = train_p)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -3.319e+00 2.184e-01 -15.197 < 2e-16 ***
## KIDSDRIV 4.699e-01 6.218e-02 7.556 4.16e-14 ***
## INCOME -3.257e-06 1.199e-06 -2.716 0.006607 **
## HOME_VAL -1.133e-06 3.808e-07 -2.976 0.002924 **
## TRAVTIME 1.334e-02 2.099e-03 6.358 2.05e-10 ***
## BLUEBOOK -2.398e-05 5.317e-06 -4.510 6.48e-06 ***
## TIF -5.662e-02 8.224e-03 -6.884 5.82e-12 ***
## OLDCLAIM -1.552e-05 4.283e-06 -3.625 0.000289 ***
## CLM_FREQ 2.053e-01 3.176e-02 6.463 1.03e-10 ***
## MVR_PTS 1.169e-01 1.520e-02 7.688 1.49e-14 ***
## PARENT1Yes 4.679e-01 1.060e-01 4.415 1.01e-05 ***
## MSTATUSYes -4.813e-01 8.926e-02 -5.393 6.94e-08 ***
## REVOKEDYes 9.446e-01 1.001e-01 9.439 < 2e-16 ***
## URBANICITYUrban 2.351e+00 1.251e-01 18.794 < 2e-16 ***
## CAR_USECommercial 7.504e-01 1.032e-01 7.275 3.48e-13 ***
## EDUCATIONBachelors -4.367e-01 1.213e-01 -3.602 0.000316 ***
## EDUCATIONHigh School -7.829e-03 1.057e-01 -0.074 0.940938
## EDUCATIONMasters -2.629e-01 1.793e-01 -1.466 0.142611
## EDUCATIONPhD -1.356e-01 2.207e-01 -0.614 0.538901
## JOBClerical 3.041e-02 1.193e-01 0.255 0.798795
## JOBDoctor -1.187e+00 3.438e-01 -3.454 0.000552 ***
## JOBHome Maker -8.201e-02 1.623e-01 -0.505 0.613341
## JOBLawyer -3.099e-01 2.074e-01 -1.494 0.135183
## JOBManager -9.029e-01 1.583e-01 -5.704 1.17e-08 ***
## JOBProfessional -2.049e-01 1.349e-01 -1.520 0.128623
## JOBStudent -5.754e-02 1.375e-01 -0.418 0.675601
## CAR_TYPEPanel Truck 5.389e-01 1.706e-01 3.158 0.001589 **
## CAR_TYPEPickup 5.494e-01 1.117e-01 4.918 8.76e-07 ***
## CAR_TYPESports Car 9.220e-01 1.206e-01 7.643 2.12e-14 ***
## CAR_TYPESUV 7.289e-01 9.559e-02 7.625 2.44e-14 ***
## CAR_TYPEVan 6.032e-01 1.371e-01 4.399 1.09e-05 ***
## M_JOB -3.618e-01 2.079e-01 -1.740 0.081822 .
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 7533.1 on 6527 degrees of freedom
## Residual deviance: 5828.7 on 6496 degrees of freedom
## AIC: 5892.7
##
## Number of Fisher Scoring iterations: 5
cat("AIC:", AIC(logit2), " LogLik:", as.numeric(logLik(logit2)), "\n")
## AIC: 5892.661 LogLik: -2914.331
cat("Variables retained:", length(coef(logit2)) - 1, "\n")
## Variables retained: 31
Instead of algorithmic selection, I hand-pick a small set of variables that the assignment’s theoretical-effect column flags as the strongest candidates, and I use the log-transformed versions of the skewed dollar variables instead of their raw dollar form.
logit3 <- glm(TARGET_FLAG ~ KIDSDRIV + MVR_PTS + CLM_FREQ + REVOKED + URBANICITY +
CAR_USE + LOG_INCOME + HOME_OWNER + LOG_BLUEBOOK + TIF +
PARENT1 + MSTATUS,
data = train_p, family = binomial)
summary(logit3)
##
## Call:
## glm(formula = TARGET_FLAG ~ KIDSDRIV + MVR_PTS + CLM_FREQ + REVOKED +
## URBANICITY + CAR_USE + LOG_INCOME + HOME_OWNER + LOG_BLUEBOOK +
## TIF + PARENT1 + MSTATUS, family = binomial, data = train_p)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 2.58585 0.48653 5.315 1.07e-07 ***
## KIDSDRIV 0.46597 0.06029 7.729 1.09e-14 ***
## MVR_PTS 0.12338 0.01482 8.327 < 2e-16 ***
## CLM_FREQ 0.16906 0.02754 6.138 8.37e-10 ***
## REVOKEDYes 0.77203 0.08552 9.028 < 2e-16 ***
## URBANICITYUrban 2.06050 0.12428 16.579 < 2e-16 ***
## CAR_USECommercial 0.88093 0.06639 13.269 < 2e-16 ***
## LOG_INCOME -0.08220 0.01092 -7.527 5.19e-14 ***
## HOME_OWNER -0.27372 0.08410 -3.255 0.00114 **
## LOG_BLUEBOOK -0.52521 0.05257 -9.991 < 2e-16 ***
## TIF -0.05039 0.00798 -6.315 2.70e-10 ***
## PARENT1Yes 0.51817 0.10215 5.072 3.93e-07 ***
## MSTATUSYes -0.34923 0.08671 -4.028 5.63e-05 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 7533.1 on 6527 degrees of freedom
## Residual deviance: 6121.6 on 6515 degrees of freedom
## AIC: 6147.6
##
## Number of Fisher Scoring iterations: 5
cat("AIC:", AIC(logit3), " LogLik:", as.numeric(logLik(logit3)), "\n")
## AIC: 6147.591 LogLik: -3060.796
vif(logit3)
## KIDSDRIV MVR_PTS CLM_FREQ REVOKED URBANICITY
## 1.083902 1.144096 1.159780 1.002933 1.097885
## CAR_USE LOG_INCOME HOME_OWNER LOG_BLUEBOOK TIF
## 1.078922 1.163527 1.541232 1.147466 1.005685
## PARENT1 MSTATUS
## 1.401222 1.863930
All VIFs are comfortably below 5, so multicollinearity isn’t a concern for the parsimonious model.
The assignment allows substituting a ridge or LASSO fit for one of the required models as a bonus. Rather than replace anything, I fit both alongside the three required logistic models, using the same full variable set as Model 1, plus a probit link on Model 2’s selected variables as a second, smaller bonus check.
x_form <- TARGET_FLAG ~ KIDSDRIV + AGE + HOMEKIDS + YOJ + INCOME + HOME_VAL +
TRAVTIME + BLUEBOOK + TIF + OLDCLAIM + CLM_FREQ + MVR_PTS + CAR_AGE +
PARENT1 + MSTATUS + SEX + RED_CAR + REVOKED + URBANICITY + CAR_USE +
EDUCATION + JOB + CAR_TYPE + M_YOJ + M_INCOME + M_HOME_VAL + M_CAR_AGE + M_JOB
x_train <- model.matrix(x_form, data = train_p)[, -1]
y_train <- train_p$TARGET_FLAG
set.seed(7320)
cv_lasso <- cv.glmnet(x_train, y_train, family = "binomial", alpha = 1)
cv_ridge <- cv.glmnet(x_train, y_train, family = "binomial", alpha = 0)
lasso_coef <- coef(cv_lasso, s = "lambda.1se")
kept <- setdiff(rownames(lasso_coef)[which(lasso_coef[, 1] != 0)], "(Intercept)")
cat("LASSO (lambda.1se) keeps", length(kept), "of", ncol(x_train), "candidate predictors.\n")
## LASSO (lambda.1se) keeps 23 of 41 candidate predictors.
probit1 <- glm(formula(logit2), data = train_p, family = binomial(link = "probit"))
LASSO’s own feature selection is a useful cross-check on the stepwise selection in Model 2: if the two methods agree on which variables matter, that’s good evidence Model 2 isn’t just an artifact of AIC’s particular penalty.
ridge_coef <- coef(cv_ridge, s = "lambda.1se")
compare_tbl <- data.frame(
Variable = kept,
LASSO_coef = round(as.numeric(lasso_coef[kept, 1]), 4),
Ridge_coef = round(as.numeric(ridge_coef[kept, 1]), 4)
)
kable(compare_tbl, row.names = FALSE,
caption = "Variables retained by LASSO (lambda.1se) and their Ridge counterparts")
| Variable | LASSO_coef | Ridge_coef |
|---|---|---|
| KIDSDRIV | 0.3140 | 0.3062 |
| HOMEKIDS | 0.0231 | 0.0501 |
| INCOME | 0.0000 | 0.0000 |
| HOME_VAL | 0.0000 | 0.0000 |
| TRAVTIME | 0.0075 | 0.0082 |
| BLUEBOOK | 0.0000 | 0.0000 |
| TIF | -0.0341 | -0.0386 |
| CLM_FREQ | 0.1366 | 0.1710 |
| MVR_PTS | 0.0985 | 0.1004 |
| CAR_AGE | -0.0062 | -0.0052 |
| PARENT1Yes | 0.3804 | 0.3449 |
| MSTATUSYes | -0.2846 | -0.3330 |
| REVOKEDYes | 0.6011 | 0.6565 |
| URBANICITYUrban | 1.7497 | 1.3795 |
| CAR_USECommercial | 0.6691 | 0.5351 |
| EDUCATIONBachelors | -0.0796 | -0.2123 |
| EDUCATIONHigh School | 0.1210 | 0.1100 |
| EDUCATIONMasters | -0.0162 | -0.1487 |
| JOBDoctor | -0.2916 | -0.6183 |
| JOBManager | -0.5051 | -0.5467 |
| CAR_TYPEPickup | 0.0405 | 0.2696 |
| CAR_TYPESports Car | 0.3054 | 0.4599 |
| CAR_TYPESUV | 0.2292 | 0.3540 |
Every variable LASSO keeps also survived the backward-AIC selection in Model 2 (motor-vehicle points, claim history, revoked license, urbanicity, commercial use, and several of the same job/car-type dummies), which is reassuring: two quite different selection procedures, one penalty-based and one information-criterion-based, land on essentially the same set of predictors.
Rather than four separate walls of summary() output,
stargazer puts all four specifications (including the bonus
probit) in one compact table so coefficient sign, magnitude, and
significance can be compared directly across models.
stargazer(logit1, logit2, logit3, probit1, type = "latex", header = FALSE,
title = "Logistic (and probit) regression models: crash probability (TARGET\\_FLAG)",
column.labels = c("Model 1: Full","Model 2: Stepwise","Model 3: Parsimonious","Bonus: Probit"),
dep.var.labels = "P(crash)", single.row = TRUE, no.space = TRUE,
font.size = "tiny", omit.stat = c("bic"),
star.cutoffs = c(0.05, 0.01, 0.001))
The probit column uses the exact same variables as Model 2, just with a different link function, and the coefficients tell the same story on a rescaled axis (probit coefficients are always smaller in magnitude than the corresponding logit ones, roughly by a factor of 1.6 to 1.8, since the two links are fit on different underlying scales), with the same signs and essentially the same significance pattern throughout.
To illustrate omitted-variable bias, the table below tracks a few variables that appear in all three models as the specification grows from the parsimonious model to the full kitchen-sink model.
# pull coefficients by name, matching each model's actual dummy-coded names
get_coef <- function(model, name) {
cn <- names(coef(model))
hit <- cn[grepl(name, cn, fixed = TRUE)]
if (length(hit) == 0) return(NA)
coef(model)[hit[1]]
}
coef_table <- data.frame(
Variable = c("MVR_PTS", "CLM_FREQ", "URBANICITY (Urban)", "CAR_USE (Commercial)"),
Model3_parsimonious = c(get_coef(logit3,"MVR_PTS"), get_coef(logit3,"CLM_FREQ"),
get_coef(logit3,"URBANICITY"), get_coef(logit3,"CAR_USE")),
Model2_stepwise = c(get_coef(logit2,"MVR_PTS"), get_coef(logit2,"CLM_FREQ"),
get_coef(logit2,"URBANICITY"), get_coef(logit2,"CAR_USE")),
Model1_full = c(get_coef(logit1,"MVR_PTS"), get_coef(logit1,"CLM_FREQ"),
get_coef(logit1,"URBANICITY"), get_coef(logit1,"CAR_USE"))
)
kable(coef_table, digits = 3,
caption = "Coefficient stability across specifications (logit, log-odds scale)")
| Variable | Model3_parsimonious | Model2_stepwise | Model1_full | |
|---|---|---|---|---|
| MVR_PTS | MVR_PTS | 0.123 | 0.117 | 0.116 |
| CLM_FREQ | CLM_FREQ | 0.169 | 0.205 | 0.205 |
| URBANICITYUrban | URBANICITY (Urban) | 2.060 | 2.351 | 2.349 |
| CAR_USECommercial | CAR_USE (Commercial) | 0.881 | 0.750 | 0.754 |
The coefficients on MVR_PTS, CLM_FREQ,
URBANICITY, and CAR_USE are remarkably stable
across all three specifications: they don’t collapse or flip sign as I
add controls, which is reassuring: it suggests these effects aren’t
artifacts of omitted-variable bias from the handful of variables each
specification leaves out.
The naive approach: regress the dollar claim amount directly on everything, using every record (crash or not).
mlr_vars_formula <- TARGET_AMT ~ KIDSDRIV + AGE + HOMEKIDS + YOJ + INCOME + HOME_VAL +
TRAVTIME + BLUEBOOK + TIF + OLDCLAIM + CLM_FREQ + MVR_PTS + CAR_AGE +
PARENT1 + MSTATUS + SEX + RED_CAR + REVOKED + URBANICITY + CAR_USE +
EDUCATION + JOB + CAR_TYPE
mlr1 <- lm(mlr_vars_formula, data = train_p)
summ1 <- summary(mlr1)
cat("R2:", summ1$r.squared, " Adj R2:", summ1$adj.r.squared,
" F-stat:", summ1$fstatistic[1], "\n")
## R2: 0.07325004 Adj R2: 0.06811015 F-stat: 14.2513
cat("RMSE:", sqrt(mean(residuals(mlr1)^2)), "\n")
## RMSE: 4375.669
The same variable set, but the response is
log(1 + TARGET_AMT) to address the severe right skew
documented in Data Exploration.
mlr2 <- lm(update(mlr_vars_formula, log1p(TARGET_AMT) ~ .), data = train_p)
summ2 <- summary(mlr2)
cat("R2:", summ2$r.squared, " Adj R2:", summ2$adj.r.squared,
" F-stat:", summ2$fstatistic[1], "\n")
## R2: 0.2270173 Adj R2: 0.2227303 F-stat: 52.95396
pred_log <- predict(mlr2)
pred_back <- expm1(pred_log)
cat("RMSE (log scale):", sqrt(mean(residuals(mlr2)^2)), "\n")
## RMSE (log scale): 3.223216
cat("RMSE (back-transformed to $):", sqrt(mean((train_p$TARGET_AMT - pred_back)^2)), "\n")
## RMSE (back-transformed to $): 4748.125
summary(mlr2)
##
## Call:
## lm(formula = update(mlr_vars_formula, log1p(TARGET_AMT) ~ .),
## data = train_p)
##
## Residuals:
## Min 1Q Median 3Q Max
## -7.6982 -2.3347 -0.8817 2.0829 10.2750
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 4.145e-02 3.672e-01 0.113 0.910113
## KIDSDRIV 5.876e-01 9.061e-02 6.485 9.50e-11 ***
## AGE -1.732e-03 5.620e-03 -0.308 0.757893
## HOMEKIDS 5.291e-02 5.241e-02 1.010 0.312768
## YOJ -2.062e-02 1.192e-02 -1.730 0.083666 .
## INCOME -3.474e-06 1.419e-06 -2.448 0.014377 *
## HOME_VAL -1.145e-06 4.683e-07 -2.444 0.014554 *
## TRAVTIME 1.535e-02 2.556e-03 6.006 2.01e-09 ***
## BLUEBOOK -1.777e-05 6.835e-06 -2.600 0.009345 **
## TIF -6.528e-02 9.690e-03 -6.736 1.76e-11 ***
## OLDCLAIM -2.153e-05 5.789e-06 -3.719 0.000201 ***
## CLM_FREQ 2.704e-01 4.370e-02 6.187 6.49e-10 ***
## MVR_PTS 1.855e-01 2.066e-02 8.977 < 2e-16 ***
## CAR_AGE 2.906e-03 1.016e-02 0.286 0.774789
## PARENT1Yes 6.535e-01 1.607e-01 4.066 4.83e-05 ***
## MSTATUSYes -6.045e-01 1.156e-01 -5.231 1.74e-07 ***
## SEXM 2.072e-01 1.458e-01 1.421 0.155439
## RED_CARyes -2.846e-02 1.184e-01 -0.240 0.810077
## REVOKEDYes 1.309e+00 1.351e-01 9.685 < 2e-16 ***
## URBANICITYUrban 2.397e+00 1.103e-01 21.726 < 2e-16 ***
## CAR_USECommercial 1.027e+00 1.305e-01 7.872 4.07e-15 ***
## EDUCATIONBachelors -6.207e-01 1.603e-01 -3.871 0.000109 ***
## EDUCATIONHigh School 5.686e-03 1.359e-01 0.042 0.966620
## EDUCATIONMasters -5.832e-01 2.119e-01 -2.753 0.005928 **
## EDUCATIONPhD -5.071e-01 2.545e-01 -1.993 0.046348 *
## JOBClerical 8.688e-02 1.500e-01 0.579 0.562458
## JOBDoctor -9.021e-01 3.118e-01 -2.893 0.003831 **
## JOBHome Maker 1.549e-02 2.073e-01 0.075 0.940440
## JOBLawyer -1.538e-01 2.036e-01 -0.756 0.449927
## JOBManager -9.565e-01 1.637e-01 -5.842 5.39e-09 ***
## JOBProfessional -9.801e-02 1.570e-01 -0.624 0.532521
## JOBStudent -5.784e-02 1.842e-01 -0.314 0.753487
## CAR_TYPEPanel Truck 2.265e-01 2.162e-01 1.047 0.294957
## CAR_TYPEPickup 5.804e-01 1.346e-01 4.312 1.64e-05 ***
## CAR_TYPESports Car 1.189e+00 1.734e-01 6.862 7.44e-12 ***
## CAR_TYPESUV 9.691e-01 1.423e-01 6.810 1.07e-11 ***
## CAR_TYPEVan 4.900e-01 1.678e-01 2.920 0.003508 **
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 3.232 on 6491 degrees of freedom
## Multiple R-squared: 0.227, Adjusted R-squared: 0.2227
## F-statistic: 52.95 on 36 and 6491 DF, p-value: < 2.2e-16
The log transform roughly triples R^2 relative to Model 1 and substantially improves the F-statistic, which is the expected payoff from addressing the skew.
Models 1 and 2 both regress TARGET_AMT on the
whole training set, where roughly three-quarters of records are
structural zeros (no crash, no cost). That mixes two very different
questions together: “does a crash happen?” and “if it happens, how
expensive is it?” As a diagnostic, I also fit the severity question on
its own, restricting to the 1722 records that actually crashed.
crash_only <- train_p %>% filter(TARGET_FLAG == 1)
mlr3 <- lm(update(mlr_vars_formula, log(TARGET_AMT) ~ .), data = crash_only)
summ3 <- summary(mlr3)
cat("n =", nrow(crash_only), " R2:", summ3$r.squared, " Adj R2:", summ3$adj.r.squared,
" F-stat:", summ3$fstatistic[1], " p-value:",
pf(summ3$fstatistic[1], summ3$fstatistic[2], summ3$fstatistic[3], lower.tail = FALSE), "\n")
## n = 1722 R2: 0.02694921 Adj R2: 0.006159998 F-stat: 1.296307 p-value: 0.1130199
summary(mlr3)
##
## Call:
## lm(formula = update(mlr_vars_formula, log(TARGET_AMT) ~ .), data = crash_only)
##
## Residuals:
## Min 1Q Median 3Q Max
## -3.9263 -0.3991 0.0424 0.4066 3.2587
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 7.955e+00 1.856e-01 42.870 < 2e-16 ***
## KIDSDRIV -4.299e-02 3.627e-02 -1.185 0.23614
## AGE 1.373e-03 2.474e-03 0.555 0.57898
## HOMEKIDS 1.075e-02 2.454e-02 0.438 0.66153
## YOJ -3.729e-03 5.739e-03 -0.650 0.51596
## INCOME -8.007e-07 7.858e-07 -1.019 0.30838
## HOME_VAL 1.812e-07 2.328e-07 0.778 0.43653
## TRAVTIME -3.331e-04 1.276e-03 -0.261 0.79409
## BLUEBOOK 1.213e-05 3.510e-06 3.457 0.00056 ***
## TIF 7.578e-04 4.871e-03 0.156 0.87639
## OLDCLAIM 3.349e-06 2.536e-06 1.320 0.18687
## CLM_FREQ -2.453e-02 1.804e-02 -1.360 0.17401
## MVR_PTS 1.565e-02 7.896e-03 1.982 0.04760 *
## CAR_AGE -4.423e-03 5.053e-03 -0.875 0.38154
## PARENT1Yes 2.637e-02 6.796e-02 0.388 0.69800
## MSTATUSYes -8.124e-02 5.686e-02 -1.429 0.15326
## SEXM 1.087e-01 7.531e-02 1.444 0.14896
## RED_CARyes 1.689e-02 5.724e-02 0.295 0.76798
## REVOKEDYes -8.433e-02 5.778e-02 -1.459 0.14464
## URBANICITYUrban 6.170e-02 8.690e-02 0.710 0.47781
## CAR_USECommercial -5.328e-03 6.047e-02 -0.088 0.92980
## EDUCATIONBachelors 4.567e-03 7.403e-02 0.062 0.95082
## EDUCATIONHigh School 4.298e-02 5.935e-02 0.724 0.46909
## EDUCATIONMasters 1.435e-01 1.030e-01 1.394 0.16360
## EDUCATIONPhD 1.656e-01 1.302e-01 1.271 0.20381
## JOBClerical -4.507e-03 6.725e-02 -0.067 0.94657
## JOBDoctor 6.890e-02 2.208e-01 0.312 0.75503
## JOBHome Maker -5.480e-02 9.949e-02 -0.551 0.58186
## JOBLawyer 4.400e-03 1.056e-01 0.042 0.96675
## JOBManager 2.554e-03 9.276e-02 0.028 0.97804
## JOBProfessional 6.552e-02 7.496e-02 0.874 0.38220
## JOBStudent -1.047e-02 8.126e-02 -0.129 0.89747
## CAR_TYPEPanel Truck 1.627e-02 1.100e-01 0.148 0.88247
## CAR_TYPEPickup 3.966e-02 6.779e-02 0.585 0.55862
## CAR_TYPESports Car 3.331e-02 8.687e-02 0.383 0.70140
## CAR_TYPESUV 1.018e-01 7.627e-02 1.335 0.18207
## CAR_TYPEVan -4.338e-02 8.874e-02 -0.489 0.62499
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.7909 on 1685 degrees of freedom
## Multiple R-squared: 0.02695, Adjusted R-squared: 0.00616
## F-statistic: 1.296 on 36 and 1685 DF, p-value: 0.113
This is the most important substantive finding in the cost side of
the assignment: conditional on a crash occurring, almost none of these
variables explain how expensive the claim is. Adjusted R^2 is close to
zero and the overall F-test is not significant. The variables that
predict whether someone crashes (MVR_PTS,
URBANICITY, CLM_FREQ, etc.) tell you almost
nothing about how much the claim costs once a crash has
happened; those appear to be two separate data-generating processes. I
keep this model in the write-up specifically because it’s
counter-intuitive and, to the boss’s likely question of “why doesn’t
reckless driving predict expensive claims,” the honest answer is that
the size of a claim is driven mostly by things this data set doesn’t
observe (impact severity, what was hit, injury involvement), not by the
driver characteristics that predict crash frequency.
stargazer(mlr1, mlr2, mlr3, type = "latex", header = FALSE,
title = "Linear regression models: claim cost",
column.labels = c("Model 1: raw \\$ (all)","Model 2: log1p (all)","Model 3: log severity (crash-only)"),
dep.var.labels.include = FALSE,
single.row = TRUE, no.space = TRUE, font.size = "tiny",
omit.stat = c("bic"), star.cutoffs = c(0.05, 0.01, 0.001))
The stargazer table makes the earlier point visually obvious: almost every significance star in Model 1 and Model 2 (fit on the full, zero-inflated population) disappears in Model 3 (fit on crashes only). The same variables that light up when predicting whether a claim happens go dark when predicting how big it is.
I evaluate each model on the training data at the assignment’s specified 0.5 classification threshold, using AIC/log-likelihood (in-sample fit, penalized for complexity, where applicable) plus the full confusion-matrix battery. LASSO and Ridge don’t have a comparable AIC/log-likelihood (they’re fit by cross-validated penalty, not maximum likelihood), so those cells are left blank rather than filled with a misleading number.
score_logit <- function(model, data, y) {
p <- predict(model, newdata = data, type = "response")
pred <- factor(ifelse(p >= 0.5, 1, 0), levels = c(0,1))
cm <- confusionMatrix(pred, factor(y, levels = c(0,1)), positive = "1")
roc_obj <- roc(y, p, quiet = TRUE)
data.frame(
AIC = AIC(model),
LogLik = as.numeric(logLik(model)),
Accuracy = cm$overall["Accuracy"],
ErrorRate = 1 - cm$overall["Accuracy"],
Sensitivity = cm$byClass["Sensitivity"],
Specificity = cm$byClass["Specificity"],
Precision = cm$byClass["Precision"],
F1 = cm$byClass["F1"],
AUC = as.numeric(auc(roc_obj))
)
}
score_penalized <- function(cvfit, x, y) {
p <- as.numeric(predict(cvfit, newx = x, s = "lambda.1se", type = "response"))
pred <- factor(ifelse(p >= 0.5, 1, 0), levels = c(0,1))
cm <- confusionMatrix(pred, factor(y, levels = c(0,1)), positive = "1")
roc_obj <- roc(y, p, quiet = TRUE)
data.frame(
AIC = NA_real_, LogLik = NA_real_,
Accuracy = cm$overall["Accuracy"],
ErrorRate = 1 - cm$overall["Accuracy"],
Sensitivity = cm$byClass["Sensitivity"],
Specificity = cm$byClass["Specificity"],
Precision = cm$byClass["Precision"],
F1 = cm$byClass["F1"],
AUC = as.numeric(auc(roc_obj))
)
}
y_train <- train_p$TARGET_FLAG
res1 <- score_logit(logit1, train_p, y_train)
res2 <- score_logit(logit2, train_p, y_train)
res3 <- score_logit(logit3, train_p, y_train)
res4 <- score_logit(probit1, train_p, y_train)
res5 <- score_penalized(cv_lasso, x_train, y_train)
res6 <- score_penalized(cv_ridge, x_train, y_train)
comp <- rbind(Model1_Full = res1, Model2_Stepwise = res2, Model3_Parsimonious = res3,
Bonus_Probit = res4, Bonus_LASSO = res5, Bonus_Ridge = res6)
kable(comp, digits = 4, row.names = TRUE,
caption = "Logistic model comparison on training data, 0.5 threshold")
| AIC | LogLik | Accuracy | ErrorRate | Sensitivity | Specificity | Precision | F1 | AUC | |
|---|---|---|---|---|---|---|---|---|---|
| Model1_Full | 5905.352 | -2910.676 | 0.7930 | 0.2070 | 0.4262 | 0.9245 | 0.6691 | 0.5208 | 0.8146 |
| Model2_Stepwise | 5892.661 | -2914.331 | 0.7935 | 0.2065 | 0.4245 | 0.9257 | 0.6719 | 0.5203 | 0.8140 |
| Model3_Parsimonious | 6147.592 | -3060.796 | 0.7774 | 0.2226 | 0.3374 | 0.9351 | 0.6506 | 0.4444 | 0.7873 |
| Bonus_Probit | 5897.858 | -2916.929 | 0.7924 | 0.2076 | 0.4181 | 0.9266 | 0.6710 | 0.5152 | 0.8139 |
| Bonus_LASSO | NA | NA | 0.7851 | 0.2149 | 0.3188 | 0.9521 | 0.7047 | 0.4390 | 0.8069 |
| Bonus_Ridge | NA | NA | 0.7854 | 0.2146 | 0.3171 | 0.9532 | 0.7082 | 0.4380 | 0.8102 |
roc1 <- roc(y_train, predict(logit1, type = "response"), quiet = TRUE)
roc2 <- roc(y_train, predict(logit2, type = "response"), quiet = TRUE)
roc3 <- roc(y_train, predict(logit3, type = "response"), quiet = TRUE)
roc4 <- roc(y_train, predict(probit1, type = "response"), quiet = TRUE)
roc5 <- roc(y_train, as.numeric(predict(cv_lasso, newx = x_train, s = "lambda.1se", type = "response")), quiet = TRUE)
roc6 <- roc(y_train, as.numeric(predict(cv_ridge, newx = x_train, s = "lambda.1se", type = "response")), quiet = TRUE)
plot(roc1, col = "steelblue", main = "ROC: all six logistic specifications")
lines(roc2, col = "darkorange")
lines(roc3, col = "seagreen")
lines(roc4, col = "purple")
lines(roc5, col = "black")
lines(roc6, col = "grey50")
legend("bottomright", cex = 0.7,
legend = c("Model 1 (full)","Model 2 (stepwise)","Model 3 (parsimonious)",
"Bonus: probit","Bonus: LASSO","Bonus: Ridge"),
col = c("steelblue","darkorange","seagreen","purple","black","grey50"), lwd = 2)
ROC curves for all six models
The probit model lands within a rounding error of Model 2’s AUC and accuracy, which makes sense since it uses the identical variable set. LASSO and Ridge trade a little AUC (roughly 0.807-0.810 versus Model 2’s 0.814) for higher specificity, consistent with a penalty that shrinks marginal coefficients toward zero and makes the model slightly more conservative about flagging a crash. None of the three bonus models beats Model 2 outright, which is itself useful evidence that Model 2 was already a well-specified, non-overfit model rather than one a fancier estimator could visibly improve on.
Model selection: Model 2 (backward-stepwise). Model
2 actually has the lowest AIC of the three despite dropping
several variables from Model 1, a cleaner example of parsimony paying
off than I expected going in. Its accuracy, sensitivity, specificity,
and AUC are statistically indistinguishable from the full kitchen-sink
model, so there’s no accuracy cost to dropping the variables
stepAIC removed. Model 3 is the most interpretable and by
far the smallest, but it gives up close to 3 points of AUC relative to
Models 1 and 2, and the trade for parsimony there is real, not free.
Given the assignment explicitly asks whether I’d trade a bit of
performance for parsimony, my answer is: not this time, because Model
2’s extra variables aren’t just noise. The job category and car type
variables it retains are individually significant and carry sensible
signs (see interpretation below).
or_table <- exp(cbind(OddsRatio = coef(logit2), confint.default(logit2)))
kable(round(or_table, 3), caption = "Odds ratios, selected logistic model (Model 2)")
| OddsRatio | 2.5 % | 97.5 % | |
|---|---|---|---|
| (Intercept) | 0.036 | 0.024 | 0.056 |
| KIDSDRIV | 1.600 | 1.416 | 1.807 |
| INCOME | 1.000 | 1.000 | 1.000 |
| HOME_VAL | 1.000 | 1.000 | 1.000 |
| TRAVTIME | 1.013 | 1.009 | 1.018 |
| BLUEBOOK | 1.000 | 1.000 | 1.000 |
| TIF | 0.945 | 0.930 | 0.960 |
| OLDCLAIM | 1.000 | 1.000 | 1.000 |
| CLM_FREQ | 1.228 | 1.154 | 1.307 |
| MVR_PTS | 1.124 | 1.091 | 1.158 |
| PARENT1Yes | 1.597 | 1.297 | 1.965 |
| MSTATUSYes | 0.618 | 0.519 | 0.736 |
| REVOKEDYes | 2.572 | 2.114 | 3.129 |
| URBANICITYUrban | 10.496 | 8.214 | 13.412 |
| CAR_USECommercial | 2.118 | 1.730 | 2.593 |
| EDUCATIONBachelors | 0.646 | 0.509 | 0.820 |
| EDUCATIONHigh School | 0.992 | 0.807 | 1.221 |
| EDUCATIONMasters | 0.769 | 0.541 | 1.093 |
| EDUCATIONPhD | 0.873 | 0.566 | 1.346 |
| JOBClerical | 1.031 | 0.816 | 1.302 |
| JOBDoctor | 0.305 | 0.155 | 0.598 |
| JOBHome Maker | 0.921 | 0.670 | 1.266 |
| JOBLawyer | 0.734 | 0.489 | 1.101 |
| JOBManager | 0.405 | 0.297 | 0.553 |
| JOBProfessional | 0.815 | 0.625 | 1.061 |
| JOBStudent | 0.944 | 0.721 | 1.236 |
| CAR_TYPEPanel Truck | 1.714 | 1.227 | 2.395 |
| CAR_TYPEPickup | 1.732 | 1.392 | 2.156 |
| CAR_TYPESports Car | 2.514 | 1.985 | 3.185 |
| CAR_TYPESUV | 2.073 | 1.719 | 2.500 |
| CAR_TYPEVan | 1.828 | 1.397 | 2.392 |
| M_JOB | 0.696 | 0.463 | 1.047 |
Two examples, one positive and one negative coefficient, as requested:
MVR_PTS (positive, log-odds
coefficient around 0.117): each additional motor-vehicle-record point
multiplies the odds of a crash by about 1.124, i.e. roughly a 12.4%
increase in the odds of a crash per point. Someone with 5 points on
their record has odds of crashing roughly 1.79 times higher than an
otherwise-identical driver with zero points, a large, intuitive
effect.HOME_VAL (negative, coefficient near
zero on the dollar scale because home value is measured in single
dollars): a $10,000 increase in home value is associated with roughly a
1.1% decrease in the odds of a crash, consistent with the
“homeowners drive more responsibly” prior in the assignment sheet.Odds ratios are the standard way to report a logistic coefficient, but they don’t always convey practical size on their own, so here’s the same model translated into predicted probabilities for two illustrative policyholders who differ only on the handful of variables below:
mk_profile <- function(...) {
base <- data.frame(
KIDSDRIV = 0, AGE = 45, HOMEKIDS = 0, YOJ = 11, INCOME = 55000,
HOME_VAL = 150000, TRAVTIME = 33, BLUEBOOK = 15000, TIF = 6,
OLDCLAIM = 0, CLM_FREQ = 0, MVR_PTS = 0, CAR_AGE = 8,
PARENT1 = factor("No", levels = levels(train_p$PARENT1)),
MSTATUS = factor("Yes", levels = levels(train_p$MSTATUS)),
SEX = factor("F", levels = levels(train_p$SEX)),
RED_CAR = factor("no", levels = levels(train_p$RED_CAR)),
REVOKED = factor("No", levels = levels(train_p$REVOKED)),
URBANICITY = factor("Urban", levels = levels(train_p$URBANICITY)),
CAR_USE = factor("Private", levels = levels(train_p$CAR_USE)),
EDUCATION = factor("Bachelors", levels = levels(train_p$EDUCATION)),
JOB = factor("Clerical", levels = levels(train_p$JOB)),
CAR_TYPE = factor("Minivan", levels = levels(train_p$CAR_TYPE)),
M_YOJ = 0, M_INCOME = 0, M_HOME_VAL = 0, M_CAR_AGE = 0, M_JOB = 0
)
modifyList(base, list(...)) %>% as.data.frame()
}
low_risk <- mk_profile()
high_risk <- mk_profile(MVR_PTS = 6, CLM_FREQ = 3, OLDCLAIM = 8000,
REVOKED = factor("Yes", levels = levels(train_p$REVOKED)),
CAR_USE = factor("Commercial", levels = levels(train_p$CAR_USE)),
MSTATUS = factor("No", levels = levels(train_p$MSTATUS)),
PARENT1 = factor("Yes", levels = levels(train_p$PARENT1)),
KIDSDRIV = 1, TIF = 1)
profiles <- rbind(low_risk, high_risk)
profiles$P_crash <- round(predict(logit2, newdata = profiles, type = "response"), 3)
kable(data.frame(Profile = c("Married homeowner, clean record, private use",
"Single parent, 6 MVR points, revoked license, commercial use"),
P_crash = profiles$P_crash),
caption = "Predicted crash probability: two contrasting policyholder profiles")
| Profile | P_crash |
|---|---|
| Married homeowner, clean record, private use | 0.078 |
| Single parent, 6 MVR points, revoked license, commercial use | 0.893 |
Holding everything else at the same baseline values, moving from the low-risk profile to the high-risk one (a handful of realistic changes, not an extreme hypothetical) moves the predicted crash probability from 8% to 89%, roughly an 11.4x increase in probability. That’s the practical, real-world size of what the odds ratios above represent: this isn’t a model where the “significant” variables move risk by a percentage point or two. A realistic combination of a few of them is the difference between a policyholder who’s very unlikely to file a claim and one who’s more likely than not to.
vif2 <- vif(logit2)
# vif() returns a matrix (with GVIF^(1/(2*Df))) for models containing factors
if (is.matrix(vif2)) {
vif2_tbl <- data.frame(Variable = rownames(vif2), GVIF_adj = round(vif2[, 3], 2))
} else {
vif2_tbl <- data.frame(Variable = names(vif2), GVIF_adj = round(vif2, 2))
}
kable(vif2_tbl, row.names = FALSE,
caption = "Variance inflation (Model 2, selected logistic model)")
| Variable | GVIF_adj |
|---|---|
| KIDSDRIV | 1.04 |
| INCOME | 1.57 |
| HOME_VAL | 1.36 |
| TRAVTIME | 1.02 |
| BLUEBOOK | 1.32 |
| TIF | 1.01 |
| OLDCLAIM | 1.28 |
| CLM_FREQ | 1.21 |
| MVR_PTS | 1.08 |
| PARENT1 | 1.20 |
| MSTATUS | 1.37 |
| REVOKED | 1.14 |
| URBANICITY | 1.07 |
| CAR_USE | 1.57 |
| EDUCATION | 1.28 |
| JOB | 1.20 |
| CAR_TYPE | 1.10 |
| M_JOB | 1.66 |
cat("Max adjusted GVIF:", round(max(vif2_tbl$GVIF_adj), 2), "\n")
## Max adjusted GVIF: 1.66
Even in the larger, 26-plus-variable stepwise model, the worst adjusted VIF stays well under the usual rule-of-thumb cutoff of 5, so multicollinearity isn’t distorting these coefficient estimates despite the number of dummy variables in play.
mlr_metrics <- function(model, label) {
s <- summary(model)
data.frame(Model = label, R2 = s$r.squared, AdjR2 = s$adj.r.squared,
F = unname(s$fstatistic[1]), RMSE = sqrt(mean(residuals(model)^2)))
}
mlr_comp <- rbind(
mlr_metrics(mlr1, "Model 1: raw $ (full data)"),
mlr_metrics(mlr2, "Model 2: log1p (full data)"),
mlr_metrics(mlr3, "Model 3: log severity (crash-only)")
)
kable(mlr_comp, digits = 4, row.names = FALSE, caption = "Linear model comparison")
| Model | R2 | AdjR2 | F | RMSE |
|---|---|---|---|---|
| Model 1: raw $ (full data) | 0.0733 | 0.0681 | 14.2513 | 4375.6688 |
| Model 2: log1p (full data) | 0.2270 | 0.2227 | 52.9540 | 3.2232 |
| Model 3: log severity (crash-only) | 0.0269 | 0.0062 | 1.2963 | 0.7824 |
vif_mlr2 <- vif(mlr2)
if (is.matrix(vif_mlr2)) {
vif_mlr2_tbl <- data.frame(Variable = rownames(vif_mlr2), GVIF_adj = round(vif_mlr2[, 3], 2))
} else {
vif_mlr2_tbl <- data.frame(Variable = names(vif_mlr2), GVIF_adj = round(vif_mlr2, 2))
}
kable(vif_mlr2_tbl, row.names = FALSE,
caption = "Variance inflation (Model 2, selected linear model)")
| Variable | GVIF_adj |
|---|---|
| KIDSDRIV | 1.15 |
| AGE | 1.21 |
| HOMEKIDS | 1.45 |
| YOJ | 1.19 |
| INCOME | 1.65 |
| HOME_VAL | 1.47 |
| TRAVTIME | 1.02 |
| BLUEBOOK | 1.43 |
| TIF | 1.01 |
| OLDCLAIM | 1.29 |
| CLM_FREQ | 1.27 |
| MVR_PTS | 1.11 |
| CAR_AGE | 1.41 |
| PARENT1 | 1.36 |
| MSTATUS | 1.41 |
| SEX | 1.82 |
| RED_CAR | 1.35 |
| REVOKED | 1.13 |
| URBANICITY | 1.12 |
| CAR_USE | 1.57 |
| EDUCATION | 1.28 |
| JOB | 1.19 |
| CAR_TYPE | 1.18 |
cat("Max adjusted GVIF:", round(max(vif_mlr2_tbl$GVIF_adj), 2), "\n")
## Max adjusted GVIF: 1.82
All VIFs for the selected linear model stay comfortably under 5 as well.
par(mfrow = c(2,2))
plot(mlr2)
Diagnostic plots for the selected linear model
par(mfrow = c(1,1))
Model selection: Model 2 (log1p, full data). R^2 nearly triples relative to the raw-dollar Model 1, and the F-statistic is far more significant, so on the assignment’s own suggested criteria (R^2, RMSE, F-statistic) Model 2 wins outright. The residual diagnostics aren’t clean, though, and I want to be upfront about that rather than bury it: the Scale-Location plot still shows a funnel shape and the Q-Q plot has a heavy tail, both echoes of the fact that roughly three-quarters of the outcome variable is exactly zero. A log transform narrows that problem, it doesn’t eliminate it, because no monotonic transform can make a zero-inflated variable look normal.
That’s exactly why I keep Model 3 (the crash-only severity regression) as a companion piece rather than a competing candidate for “the” cost model: it isolates the “how much, given a crash” question from the “zero vs. positive” question, and its near-zero R^2 is itself the finding worth reporting: claim severity is close to unpredictable from these particular policyholder characteristics, even though claim frequency (Model 2 of the logistic section) is quite predictable from the very same variables. For generating point predictions on the evaluation set below, I use the two-part combination that’s standard for this kind of zero-inflated outcome: predicted cost = P(crash) x E[cost | crash], using the selected logistic model for the first term and Model 2 for the second, rather than either the raw full-population OLS or the crash-only severity model alone.
Using the selected models (logistic Model 2 for probability/classification, and linear Model 2 for the dollar amount, applied only to records classified as a crash), I score all 1633 evaluation records at the assignment’s required 0.5 threshold.
p_eval <- predict(logit2, newdata = test_p, type = "response")
flag_eval <- ifelse(p_eval >= 0.5, 1, 0)
amt_if_crash <- expm1(predict(mlr2, newdata = test_p))
amt_if_crash <- pmax(amt_if_crash, 0)
amt_eval <- ifelse(flag_eval == 1, amt_if_crash, 0)
predictions <- data.frame(
INDEX = test_p$INDEX,
P_TARGET_FLAG = round(p_eval, 4),
TARGET_FLAG_PRED = flag_eval,
TARGET_AMT_PRED = round(amt_eval, 2)
)
write.csv(predictions, "evaluation_predictions.csv", row.names = FALSE)
kable(head(predictions, 10), caption = "First 10 evaluation-set predictions")
| INDEX | P_TARGET_FLAG | TARGET_FLAG_PRED | TARGET_AMT_PRED |
|---|---|---|---|
| 5 | 0.0904 | 0 | 0.00 |
| 8 | 0.3121 | 0 | 0.00 |
| 26 | 0.4720 | 0 | 0.00 |
| 40 | 0.1913 | 0 | 0.00 |
| 45 | 0.0566 | 0 | 0.00 |
| 55 | 0.6258 | 1 | 108.36 |
| 61 | 0.9079 | 1 | 1265.51 |
| 66 | 0.0184 | 0 | 0.00 |
| 67 | 0.2879 | 0 | 0.00 |
| 71 | 0.3191 | 0 | 0.00 |
cat("Predicted crash rate on evaluation set:", mean(flag_eval), "\n")
## Predicted crash rate on evaluation set: 0.1702388
cat("Actual crash rate on training set: ", mean(train_p$TARGET_FLAG), "\n")
## Actual crash rate on training set: 0.2637868
The predicted crash rate on the evaluation set lines up closely with the training rate, which is a basic sanity check that the model isn’t systematically over- or under-calling crashes on new data.
Most versions of this assignment ship an evaluation file with blank
targets. This copy of insurance-testing-data2.csv happens
to still carry the true TARGET_FLAG and
TARGET_AMT values, so, purely as an extra validation step
and not part of the required deliverable, I use them to check
out-of-sample performance:
true_flag <- test_raw$TARGET_FLAG
true_amt <- test_raw$TARGET_AMT
holdout_auc <- as.numeric(auc(roc(true_flag, p_eval, quiet = TRUE)))
holdout_acc <- mean(flag_eval == true_flag)
cat("Holdout AUC:", round(holdout_auc, 4), " Holdout accuracy:", round(holdout_acc, 4), "\n")
## Holdout AUC: 0.8082 Holdout accuracy: 0.7936
holdout_rmse <- sqrt(mean((amt_eval - true_amt)^2))
cat("Holdout $ RMSE:", round(holdout_rmse, 2), "\n")
## Holdout $ RMSE: 5508.76
Out-of-sample AUC and accuracy come in essentially identical to the training-set numbers reported above, which is reassuring evidence that Model 2 isn’t overfit to the training data.
The two questions this assignment asks, will someone crash and how
much will it cost, turn out to have very different answers in terms of
how predictable they are from the variables provided. Crash probability
is well-explained by past behavior (MVR_PTS,
CLM_FREQ, prior REVOKED status), vehicle use,
and, unexpectedly to me, urban/rural location, which alone carries close
to a 10x odds ratio. Claim severity, once a crash has happened, is close
to unexplainable by anything in this data set. The honest conclusion,
and one I’d flag to a manager rather than paper over, is that better
cost prediction would need information this file doesn’t contain (impact
type, injury involvement, vehicle repair complexity) rather than more
clever modeling of the variables already here.