Load/Clean Data (Carson)

# --- Load All Required Libraries ---
# Core data manipulation
library(tidyverse)     # includes ggplot2, dplyr, readr, etc.
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.3     ✔ readr     2.1.4
## ✔ forcats   1.0.0     ✔ stringr   1.5.0
## ✔ ggplot2   3.5.2     ✔ tibble    3.2.1
## ✔ lubridate 1.9.2     ✔ tidyr     1.3.0
## ✔ purrr     1.0.2     
## ── 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(data.table)    # fast data manipulation
## 
## Attaching package: 'data.table'
## 
## The following objects are masked from 'package:lubridate':
## 
##     hour, isoweek, mday, minute, month, quarter, second, wday, week,
##     yday, year
## 
## The following objects are masked from 'package:dplyr':
## 
##     between, first, last
## 
## The following object is masked from 'package:purrr':
## 
##     transpose
library(caret)         # train/test splitting, ML tools
## Loading required package: lattice
## 
## Attaching package: 'caret'
## 
## The following object is masked from 'package:purrr':
## 
##     lift
library(Metrics)       # evaluation metrics
## Warning: package 'Metrics' was built under R version 4.3.3
## 
## Attaching package: 'Metrics'
## 
## The following objects are masked from 'package:caret':
## 
##     precision, recall
# Plotting and visualization
library(ggthemes)      # enhanced ggplot themes
## Warning: package 'ggthemes' was built under R version 4.3.3
library(ggcorrplot)    # correlation plots
## Warning: package 'ggcorrplot' was built under R version 4.3.3
library(ggrepel)       # improved text labels for ggplot
## Warning: package 'ggrepel' was built under R version 4.3.2
library(GGally)        # extended ggplot
## Warning: package 'GGally' was built under R version 4.3.3
## Registered S3 method overwritten by 'GGally':
##   method from   
##   +.gg   ggplot2
library(ggfortify)     # auto plotting for statistical objects
## Warning: package 'ggfortify' was built under R version 4.3.3
library(patchwork)     # combine plots
## Warning: package 'patchwork' was built under R version 4.3.3
library(lindia)        # linear model diagnostics
## Warning: package 'lindia' was built under R version 4.3.3
# Modeling tools
library(xgboost)       # gradient boosting
## Warning: package 'xgboost' was built under R version 4.3.3
## 
## Attaching package: 'xgboost'
## 
## The following object is masked from 'package:dplyr':
## 
##     slice
library(rpart)         # decision trees
library(rpart.plot)    # decision tree visualization
library(kknn)          # k-nearest neighbors
## Warning: package 'kknn' was built under R version 4.3.3
## 
## Attaching package: 'kknn'
## 
## The following object is masked from 'package:caret':
## 
##     contr.dummy
library(SHAPforxgboost) # SHAP values for xgboost
## Warning: package 'SHAPforxgboost' was built under R version 4.3.3
library(knitr)         # table formatting for outputs
library(broom)         # tidy model outputs
#########################
# Data Pre Processing
#########################

# Load dataset
obesity <- read_csv("ObesityDataSet_raw_and_data_sinthetic.csv")
## Rows: 2111 Columns: 17
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (9): Gender, family_history_with_overweight, FAVC, CAEC, SMOKE, SCC, CAL...
## dbl (8): Age, Height, Weight, FCVC, NCP, CH2O, FAF, TUE
## 
## ℹ 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.
# Define readable feature names mapping
feature_names <- c(
  "family_history_with_overweight" = "Family History",
  "MTRANS" = "Transportation Mode",
  "CAEC" = "Eating Between Meals",
  "FAVC" = "High-Calorie Food", 
  "CALC" = "Alcohol Consumption",
  "FCVC" = "Fruit & Veg. Consumption",
  "FAF" = "Physical Activity",
  "SCC" = "Calorie Monitoring",
  "NCP" = "Number of Meals",
  "CH2O" = "Water Consumption",
  "TUE" = "Technology Use Time",
  "SMOKE" = "Smoking Status"
)

# Utility function to rename features consistently
rename_with_mapping <- function(df_or_vector, mapping = feature_names) {
  # Check if input is a data frame
  if (is.data.frame(df_or_vector)) {
    # For data frames, rename columns
    cols_to_rename <- intersect(colnames(df_or_vector), names(mapping))
    if (length(cols_to_rename) > 0) {
      colnames(df_or_vector)[match(cols_to_rename, colnames(df_or_vector))] <- 
        mapping[cols_to_rename]
    }
  } else if (is.matrix(df_or_vector) || is.vector(df_or_vector)) {
    # For matrices or vectors, rename rows or names
    if (!is.null(rownames(df_or_vector))) {
      names_to_rename <- intersect(rownames(df_or_vector), names(mapping))
      if (length(names_to_rename) > 0) {
        rownames(df_or_vector)[match(names_to_rename, rownames(df_or_vector))] <- 
          mapping[names_to_rename]
      }
    } else if (!is.null(names(df_or_vector))) {
      names_to_rename <- intersect(names(df_or_vector), names(mapping))
      if (length(names_to_rename) > 0) {
        names(df_or_vector)[match(names_to_rename, names(df_or_vector))] <- 
          mapping[names_to_rename]
      }
    }
  }
  
  return(df_or_vector)
}

# Clean and encode dataset in single transformation
final_data <- obesity %>%
  mutate(BMI = Weight / (Height^2)) %>%
  select(-Weight, -Height, -NObeyesdad) %>%
  mutate(
    Gender = if_else(Gender == "Female", 0, 1),
    family_history_with_overweight = if_else(family_history_with_overweight == "no", 0, 1),
    FAVC = if_else(FAVC == "no", 0, 1),
    CAEC = case_when(
      CAEC == "no" ~ 0, CAEC == "Sometimes" ~ 1, 
      CAEC == "Frequently" ~ 2, CAEC == "Always" ~ 3
    ),
    SMOKE = if_else(SMOKE == "no", 0, 1),
    SCC = if_else(SCC == "no", 0, 1),
    CALC = case_when(
      CALC == "no" ~ 0, CALC == "Sometimes" ~ 1,
      CALC == "Frequently" ~ 2, CALC == "Always" ~ 3
    ),
    MTRANS = case_when(
      MTRANS == "Walking" ~ 0, MTRANS == "Bike" ~ 1,
      MTRANS == "Public_Transportation" ~ 2, MTRANS == "Motorbike" ~ 3,
      MTRANS == "Automobile" ~ 4
    )
  )

# Consolidated function for model result assessment
append_model_result <- function(name, actual_train, pred_train, actual_test, pred_test) {
  tibble(
    Model = name,
    RMSE_Train = sqrt(mean((actual_train - pred_train)^2)),
    RMSE_Test = sqrt(mean((actual_test - pred_test)^2)),
    MAE_Train = mean(abs(actual_train - pred_train)),
    MAE_Test = mean(abs(actual_test - pred_test)),
    R2_Train = 1 - sum((pred_train - actual_train)^2) / sum((mean(actual_train) - actual_train)^2),
    R2_Test = 1 - sum((pred_test - actual_test)^2) / sum((mean(actual_test) - actual_test)^2)
  ) %>%
  mutate(across(where(is.numeric), \(x) round(x, 3)))
}

# Initialize results storage
final_model_results <- tibble()

# Create EDA dataset with factor levels preserved
EDA_data <- read_csv("ObesityDataSet_raw_and_data_sinthetic.csv") %>%
  mutate(
    BMI = Weight / (Height^2),
    CAEC = factor(CAEC, levels = c("no", "Sometimes", "Frequently", "Always")),
    CALC = factor(CALC, levels = c("no", "Sometimes", "Frequently", "Always")),
    MTRANS = factor(MTRANS, levels = c("Walking", "Bike", "Public_Transportation", "Motorbike", "Automobile")),
    FAVC = factor(FAVC, levels = c("no", "yes")),
    SMOKE = factor(SMOKE, levels = c("no", "yes")),
    SCC = factor(SCC, levels = c("no", "yes")),
    family_history_with_overweight = factor(family_history_with_overweight, levels = c("no", "yes")),
    Gender = factor(Gender, levels = c("Female", "Male"))
  )
## Rows: 2111 Columns: 17
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (9): Gender, family_history_with_overweight, FAVC, CAEC, SMOKE, SCC, CAL...
## dbl (8): Age, Height, Weight, FCVC, NCP, CH2O, FAF, TUE
## 
## ℹ 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.

EDA (Carson)

_ Here’s a quick summary of those in our initial EDA dataset:

############################
# Exploratory Data Analysis
############################
# --- Summary Stats ---

# View summary statistics for all features in EDA_data
summary(EDA_data)
##     Gender          Age            Height          Weight      
##  Female:1043   Min.   :14.00   Min.   :1.450   Min.   : 39.00  
##  Male  :1068   1st Qu.:19.95   1st Qu.:1.630   1st Qu.: 65.47  
##                Median :22.78   Median :1.700   Median : 83.00  
##                Mean   :24.31   Mean   :1.702   Mean   : 86.59  
##                3rd Qu.:26.00   3rd Qu.:1.768   3rd Qu.:107.43  
##                Max.   :61.00   Max.   :1.980   Max.   :173.00  
##  family_history_with_overweight  FAVC           FCVC            NCP       
##  no : 385                       no : 245   Min.   :1.000   Min.   :1.000  
##  yes:1726                       yes:1866   1st Qu.:2.000   1st Qu.:2.659  
##                                            Median :2.386   Median :3.000  
##                                            Mean   :2.419   Mean   :2.686  
##                                            3rd Qu.:3.000   3rd Qu.:3.000  
##                                            Max.   :3.000   Max.   :4.000  
##          CAEC      SMOKE           CH2O        SCC            FAF        
##  no        :  51   no :2067   Min.   :1.000   no :2015   Min.   :0.0000  
##  Sometimes :1765   yes:  44   1st Qu.:1.585   yes:  96   1st Qu.:0.1245  
##  Frequently: 242              Median :2.000              Median :1.0000  
##  Always    :  53              Mean   :2.008              Mean   :1.0103  
##                               3rd Qu.:2.477              3rd Qu.:1.6667  
##                               Max.   :3.000              Max.   :3.0000  
##       TUE                 CALC                        MTRANS    
##  Min.   :0.0000   no        : 639   Walking              :  56  
##  1st Qu.:0.0000   Sometimes :1401   Bike                 :   7  
##  Median :0.6253   Frequently:  70   Public_Transportation:1580  
##  Mean   :0.6579   Always    :   1   Motorbike            :  11  
##  3rd Qu.:1.0000                     Automobile           : 457  
##  Max.   :2.0000                                                 
##   NObeyesdad             BMI       
##  Length:2111        Min.   :13.00  
##  Class :character   1st Qu.:24.33  
##  Mode  :character   Median :28.72  
##                     Mean   :29.70  
##                     3rd Qu.:36.02  
##                     Max.   :50.81
# final dataset with numeric categorical features
head(final_data)
# --- Target Variable Analysis ---

# Create a clean data frame for training
train_data <- EDA_data[, c("Height", "Weight", "NObeyesdad")]
train_data$NObeyesdad <- as.factor(train_data$NObeyesdad)
train_data <- as.data.frame(train_data)

# Clean test grid
grid1 <- expand.grid(
  Height = seq(min(train_data$Height), max(train_data$Height), length.out = 200),
  Weight = seq(min(train_data$Weight), max(train_data$Weight), length.out = 200)
)
grid1 <- as.data.frame(grid1)
grid1$Height <- as.numeric(grid1$Height)
grid1$Weight <- as.numeric(grid1$Weight)

# Define formula explicitly
formula_knn <- as.formula("NObeyesdad ~ Height + Weight")

# Train model
knn_model <- kknn(formula = formula_knn, train = train_data, test = grid1, k = 15)

# Get predicted class for each point in the grid
grid1$class <- fitted(knn_model)
# Reorder class levels to match vertical visual stacking from top to bottom
levels_ordered <- c("Insufficient_Weight", "Normal_Weight",
                    "Overweight_Level_I","Overweight_Level_II",
                    "Obesity_Type_I", "Obesity_Type_II", "Obesity_Type_III")
EDA_data$NObeyesdad <- factor(EDA_data$NObeyesdad, levels = rev(levels_ordered))
grid1$class <- factor(grid1$class, levels = rev(levels_ordered))

# Plot with one unified legend
ggplot() +
  geom_tile(data = grid1, aes(x = Height, y = Weight, fill = class), alpha = 0.25) +
  geom_point(data = EDA_data, aes(x = Height, y = Weight, color = NObeyesdad), size = 1.2, alpha = 0.85) +
  scale_fill_brewer(palette = "Dark2", name = "Class") +
  scale_color_brewer(palette = "Dark2", name = "Class") +
  labs(
    title = "BMI Class Regions by Height and Weight (K-NN)",
    subtitle = "Background shows predicted regions, points show actual classes",
    x = "Height (m)",
    y = "Weight (kg)"
  ) +
  theme_minimal()

Interpretation:

# --- Correlation with BMI Only ---
# Calculate correlation matrix
cor_matrix <- final_data %>%
  select(where(is.numeric)) %>%
  cor()

# Extract and transpose BMI correlations
cor_bmi <- as.data.frame(cor_matrix["BMI", , drop = FALSE])
cor_bmi_t <- t(cor_bmi)

# Create a named vector for column renaming
col_mapping <- feature_names[names(feature_names) %in% rownames(cor_bmi_t)]
  
# Apply the mapping to rename the rows
rownames(cor_bmi_t)[match(names(col_mapping), rownames(cor_bmi_t))] <- col_mapping

# Plot horizontally
ggcorrplot::ggcorrplot(
  cor_bmi_t,
  type = "full",
  lab = TRUE,
  lab_size = 3,
  method = "square",
  colors = c("blue", "white", "red"),
  title = "Correlation of BMI with Other Features",
  ggtheme = theme_minimal()
)

Interpretaion:

# --- Single Feature BMI Variance Analysis ---

# Get variance explained by each feature
feature_importance <- tibble(
  Feature = character(),
  Variance_Explained = numeric()
)

# Features to test
features_to_test <- c(
  "Age", "FAF", "FCVC", "NCP", "CH2O", "TUE",
  "Gender", "family_history_with_overweight", "FAVC", 
  "CAEC", "SMOKE", "SCC", "CALC", "MTRANS"
)
# More efficient approach using map_dfr
feature_importance <- map_dfr(features_to_test, function(feature) {
  # For categorical features
  if (is.factor(EDA_data[[feature]]) || is.character(EDA_data[[feature]]) || 
      length(unique(EDA_data[[feature]])) < 10) {
    
    formula_str <- paste("BMI ~", feature)
    model <- aov(as.formula(formula_str), data = final_data)
    anova_result <- summary(model)
    
    ss_total <- sum(anova_result[[1]]$`Sum Sq`)
    ss_model <- anova_result[[1]]$`Sum Sq`[1]
    r2 <- ss_model / ss_total
  } else {
    formula_str <- paste("BMI ~", feature)
    model <- lm(as.formula(formula_str), data = final_data)
    r2 <- summary(model)$r.squared
  }
  
  tibble(Feature = feature, Variance_Explained = r2)
})

# Get top features
top_features <- feature_importance %>%
  arrange(desc(Variance_Explained)) %>%
  head(8) %>%
  mutate(
    # Use our rename_with_mapping function to get readable feature names
    Feature = ifelse(Feature %in% names(feature_names), 
                    feature_names[Feature], 
                    Feature),
    # Format as percentage
    Variance_Explained = paste0(round(Variance_Explained * 100, 1), "%")
  )

# Display table
kable(top_features,
     caption = "Top Features Ranked by BMI Variance Explained",
     col.names = c("Feature", "BMI Variance Explained"))
Top Features Ranked by BMI Variance Explained
Feature BMI Variance Explained
Family History 23.4%
Eating Between Meals 9.8%
Fruit & Veg. Consumption 7%
High-Calorie Food 6.1%
Age 6%
Calorie Monitoring 3.4%
Physical Activity 3.2%
Alcohol Consumption 2.9%

Interpretation:

# --- Continuous Predictor Analysis ---

# Create a unified function for continuous predictor analysis
continuous_analyzer <- function(var_name, data = EDA_data) {
  # Get readable variable label using our mapping
  var_label <- if (var_name %in% names(feature_names)) feature_names[var_name] else var_name
  
  p1 <- ggplot(data, aes_string(x = var_name, y = "BMI")) +
    geom_point(alpha = 0.5, color = "darkblue") +
    geom_smooth(method = "loess", se = FALSE, color = "red") +
    labs(title = paste("BMI vs", var_label), x = var_label, y = "BMI") +
    theme_minimal()
  
  p2 <- ggplot(data, aes_string(x = var_name)) +
    geom_histogram(fill = "skyblue", color = "black", bins = 30) +
    labs(title = paste(var_label, "Distribution"), x = var_label, y = "Count") +
    theme_minimal()
  
  # Bin BMI and compute mean of selected variable per bin
  bin_width <- (max(data$BMI, na.rm = TRUE) - min(data$BMI, na.rm = TRUE)) / 30
  data_binned <- data %>%
    mutate(BMI_bin = cut(BMI, breaks = 30)) %>%
    mutate(BMI_mid = (as.numeric(sub("\\((.+),.*", "\\1", BMI_bin)) + 
                     as.numeric(sub("[^,]*,([^]]*)\\]", "\\1", BMI_bin))) / 2)
  
  bin_summary <- data_binned %>%
    group_by(BMI_mid) %>%
    summarise(count = n(), avg_val = mean(.data[[var_name]], na.rm = TRUE)) %>%
    filter(!is.na(BMI_mid))

  # BMI histogram shaded by average feature
  p3 <- ggplot(bin_summary, aes(x = BMI_mid, y = count, fill = avg_val)) +
    geom_col(color = "black") +
    scale_fill_gradient(low = "green", high = "red", name = paste("Avg", var_label)) +
    labs(title = paste("BMI Distribution\n(shaded by avg.", var_label, ")"),
         x = "BMI", y = "Count", fill = paste("Avg", var_label)) +
    theme_minimal()
  
  # Combine plots with patchwork
  library(patchwork)
  (p1 | p2) / p3
}

# Apply to Age
continuous_analyzer("Age")
## Warning: `aes_string()` was deprecated in ggplot2 3.0.0.
## ℹ Please use tidy evaluation idioms with `aes()`.
## ℹ See also `vignette("ggplot2-in-packages")` for more information.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
## `geom_smooth()` using formula = 'y ~ x'

continuous_analyzer("FCVC")
## `geom_smooth()` using formula = 'y ~ x'

continuous_analyzer("NCP")
## `geom_smooth()` using formula = 'y ~ x'

continuous_analyzer("FAF")
## `geom_smooth()` using formula = 'y ~ x'

continuous_analyzer("TUE")
## `geom_smooth()` using formula = 'y ~ x'

continuous_analyzer("CH2O")
## `geom_smooth()` using formula = 'y ~ x'

# --- Categorical & Binary Predictor Analysis ---

# Define a reusable boxplot function with count labels
make_bmi_boxplot <- function(var_name) {
  # Get readable variable label using our mapping
  var_label <- if (var_name %in% names(feature_names)) feature_names[var_name] else var_name
  
  # Calculate counts for each level
  counts <- table(EDA_data[[var_name]])
  
  # Create labels with counts
  count_labels <- paste0(names(counts), "\n(n=", counts, ")")
  
  ggplot(EDA_data, aes_string(x = var_name, y = "BMI")) +
    geom_boxplot(fill = "skyblue", color = "black", outlier.alpha = 0.3) +
    scale_x_discrete(labels = count_labels) +  # Apply the count labels
    labs(
      x = var_label,
      y = "BMI",
      title = paste("BMI by", var_label)
    ) +
    theme_minimal(base_size = 14) +
    theme(
      axis.text.x = element_text(angle = 30, hjust = 1, size = 16),
      axis.text.y = element_text(size = 16),
      plot.title = element_text(size = 16, face = "bold"),
      axis.title = element_text(size = 16)
    )
}

# Create each plot
p1 <- make_bmi_boxplot("Gender")
p2 <- make_bmi_boxplot("family_history_with_overweight")
p3 <- make_bmi_boxplot("FAVC")
p4 <- make_bmi_boxplot("CAEC")
p5 <- make_bmi_boxplot("SMOKE")
p6 <- make_bmi_boxplot("SCC")
p7 <- make_bmi_boxplot("CALC")
p8 <- make_bmi_boxplot("MTRANS")

# Assemble in a 4x2 grid using patchwork
wrap_plots(p1, p2, p3, p4, p5, p6, p7, p8, ncol = 2) +
  plot_layout(guides = "collect")

Interpretation:

_ As a whole we see a lot of imbalance in features that seem unrealistic, and I blame this on the synthetic SMOTE data under sampling things like smokers and over sampling things like family history of obesity

Gender:

Family History:

Smoking:

Calorie Monitoring:

Mode of Transportation:

Alcohol Consumption:

always_alcohol <- EDA_data %>% 
  filter(CALC == "Always")

print(always_alcohol)
## # A tibble: 1 × 18
##   Gender   Age Height Weight family_history_with_overw…¹ FAVC   FCVC   NCP CAEC 
##   <fct>  <dbl>  <dbl>  <dbl> <fct>                       <fct> <dbl> <dbl> <fct>
## 1 Male      21    1.7     65 yes                         yes       2     1 Freq…
## # ℹ abbreviated name: ¹​family_history_with_overweight
## # ℹ 9 more variables: SMOKE <fct>, CH2O <dbl>, SCC <fct>, FAF <dbl>, TUE <dbl>,
## #   CALC <fct>, MTRANS <fct>, NObeyesdad <fct>, BMI <dbl>
# --- Collinearity Analysis ---
# --- Collinearity Analysis ---
# Remove BMI column
final_data_no_bmi <- final_data %>% select(-BMI)

# Calculate correlation matrix for numeric columns only
cor_matrix <- final_data_no_bmi %>%
  select(where(is.numeric)) %>%
  cor(use = "complete.obs")  # in case there are any missing values

# Apply readable feature names
rownames(cor_matrix) <- ifelse(rownames(cor_matrix) %in% names(feature_names),
                              feature_names[rownames(cor_matrix)],
                              rownames(cor_matrix))
colnames(cor_matrix) <- ifelse(colnames(cor_matrix) %in% names(feature_names),
                              feature_names[colnames(cor_matrix)],
                              colnames(cor_matrix))

# Plot the correlation matrix
ggcorrplot(cor_matrix,
           method = "square",
           type = "lower",
           lab = TRUE,
           lab_size = 3,
           colors = c("blue", "white", "red"),
           title = "Correlation Matrix (Excluding BMI)",
           ggtheme = theme_minimal())

############################
# Modelling Preparation
############################
# Design matrix for modeling
full_matrix <- model.matrix(BMI ~ . - 1, data = final_data)
full_label <- final_data$BMI

set.seed(123)
split <- createDataPartition(full_label, p = 0.8, list = FALSE)
train_matrix <- full_matrix[split, ]
test_matrix <- full_matrix[-split, ]
train_label <- full_label[split]
test_label <- full_label[-split]

Model 1: Linear Regression (Kylie)

(I made the calc feature numeric sense it’s ordinal and to allow its p value to be significant enough to draw inference from its coefficient value – now you can say “for each tier increased in alcohol consumption the persons BMI increase 2.45”)

#create linear regression model and print summary
lm_model <- lm(BMI ~ FAF + SCC + CALC, data = final_data)
summary(lm_model)
## 
## Call:
## lm(formula = BMI ~ FAF + SCC + CALC, data = final_data)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -16.0101  -5.5762   0.1485   5.8514  20.7423 
## 
## Coefficients:
##             Estimate Std. Error t value Pr(>|t|)    
## (Intercept)  29.6525     0.3640  81.454  < 2e-16 ***
## FAF          -1.4222     0.1975  -7.200 8.32e-13 ***
## SCC          -6.6746     0.8031  -8.311  < 2e-16 ***
## CALC          2.4446     0.3250   7.522 7.96e-14 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 7.667 on 2107 degrees of freedom
## Multiple R-squared:  0.08551,    Adjusted R-squared:  0.08421 
## F-statistic: 65.68 on 3 and 2107 DF,  p-value: < 2.2e-16
  • There are 2 significant variables that have an impact on BMI - FAF (how often the person has physical activity) and SCCyes (does the person monitor their calories daily where the answer is yes)

  • The R^2 value is 0.1045, which is very low - this implies that model explains around 10% of the variance in the model.

# --- Diagnostics ---
ggplot2::autoplot(lm_model)

Residual vs Fitted:

  • The points are relatively evenly spread around the horizontal line (0), although there are more points above the horizontal line (especially when fitted values = ~30), which implies that the model does a decent job at capturing the linear relationship between BMI and FAF, SCC, and CALC, and errors are mostly random.

  • There are no odd patterns (curved), which implies that the relationship between BMI and FAF/SCC/CALC is not linear, so a linear regression model will not cover all the complexities of that relationship.

  • The increase in points as you move from left to right on the graph (as fitted values increase) implies heteroscedasticity, or that the error term does not have constant variance.

Q-Q Plot:

  • The points fit along the diagonal like for a small portion of the graph, diverging at either end.

  • The deviation implies that the data is not normally distributed, and might be skewed as well.

# Load the library
library(broom)

# Extract residuals and predictors
resid_data <- augment(lm_model)

# Create each residual plot with custom labels

# FAF (Physical Activity Frequency)
p1 <- ggplot(resid_data, aes(x = FAF, y = .resid)) +
  geom_point(size = 1.5, alpha = 0.5) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
  scale_x_continuous(breaks = 0:3,
                     labels = c("0" = "None", "1" = "Low", "2" = "Moderate", "3" = "High")) +
  labs(title = "Residuals by Physical Activity Level (FAF)",
       x = "Physical Activity Frequency", y = "Residuals") +
  theme_minimal()

# SCC (Calories Monitoring)
p2 <- ggplot(resid_data, aes(x = SCC, y = .resid)) +
  geom_point(size = 1.5, alpha = 0.5) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
  scale_x_continuous(breaks = 0:1,
                     labels = c("0" = "No", "1" = "Yes")) +
  labs(title = "Residuals by Calories Monitoring (SCC)",
       x = "Monitors Calories?", y = "Residuals") +
  theme_minimal()

# CALC (Alcohol Consumption)
p3 <- ggplot(resid_data, aes(x = CALC, y = .resid)) +
  geom_point(size = 1.5, alpha = 0.5) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
  scale_x_continuous(breaks = 0:3,
                     labels = c("0" = "Never", "1" = "Sometimes", "2" = "Frequently", "3" = "Always")) +
  labs(title = "Residuals by Alcohol Consumption (CALC)",
       x = "Alcohol Consumption", y = "Residuals") +
  theme_minimal()

# Combine all 3 vertically
(p1 / p2 / p3)

  • Looking at the plot for Residuals vs. FAF, the points are relatively evenly distributed around the red horizontal like (y = 0), implying the error is random. However, there is no real curved trend, so the relationship between BMI and FAF is nonlinear.

  • Looking at the plot for Residual vs. SCC, both medians are close to 0, which means there’s no real bias towards either group in the model. However, the IQR for ‘no’ is much larger than the IQR for ‘yes’, implying heteroscedasticity (violating an assumption of a linear model). There are also outliers for ‘yes’, which could mean that the model does a better job of explaining instances of ‘no’ rather than ‘yes’.

  • Finally looking at the plot for Residual vs. CALC, all 3 medians are around 0 so nothing in biased, but the IQR for ‘no’ and ‘sometimes’ are much larger than ‘always’ and ‘frequently’, and while there might simply be more observations in those categories, it also could imply that the model has heteroscedasticity and violates assumptions of linear models.

#residual histogram

gg_reshist(lm_model)
## `stat_bin()` using `bins = 30`. Pick better value with `binwidth`.

  • The histogram does not have even an approximate bell shape, and it’s not symmetrical around 0 either, implying that the model/data is not normally distributed.
#cook's d

gg_cooksd(lm_model, threshold = 'matlab')

# Define the 5 most influential points
influential_points <- c(27, 69, 258, 381, 472)

# Subset the final data to show only the linear model predictors + BMI
final_data[influential_points, c("FAF", "SCC", "CALC", "BMI")]
  • Only one influential point was identified, but ideally every point should equally influence the model. This observation could make the conclusions of the model inaccurate, or it might mean the model is overfit and can’t handle new data well.
# Create train/test from original data (not matrix)
train_data_lm <- final_data[split, ]
test_data_lm <- final_data[-split, ]

# Fit model
lm_model <- lm(BMI ~ FAF + SCC + CALC, data = train_data_lm)

# Predict
lr_preds_train <- predict(lm_model, newdata = train_data_lm)
lr_preds_test <- predict(lm_model, newdata = test_data_lm)

# Actuals
actual_train <- train_data_lm$BMI
actual_test <- test_data_lm$BMI

# Metrics
lr_result <- append_model_result("Linear Regression", train_label, lr_preds_train, test_label, lr_preds_test)

# Add to final results
final_model_results <- bind_rows(final_model_results, lr_result)

lr_result

Model 3: Decision Tree (Uma)

library(rpart)
library(rpart.plot)

# Train decision tree (depth limited to 3)
dt_model <- rpart(BMI ~ ., data = final_data[split, ], method = "anova",
                  control = rpart.control(maxdepth = 3))

# Visualize the tree
rpart.plot(dt_model, type = 2, extra = 101, fallen.leaves = TRUE, 
           box.palette = "Blues", main = "Decision Tree (Max Depth = 3)")

We started by training a basic decision tree to predict weight based on all other available variables in our dataset. To keep the model intentionally simple and interpretable, we restricted the tree’s complexity in two ways:

Limited the maximum depth to only 3 levels (preventing overcomplicated branches) Set a relatively high complexity parameter (cp=0.01) to prune unnecessary splits The ‘anova’ method specification tells R we’re performing regression (predicting continuous weight values) rather than classification.

For visualization, we created a clean, informative plot showing:

The hierarchical decision points (splits) based on important predictors The predicted weight values at each terminal node (leaf) A color gradient (using a blue palette) to help distinguish between different prediction ranges Subtle drop shadows to improve readability of the tree structure

This restrained approach gives us a model that’s: 1. Easy to explain to non-technical stakeholders 2. Quick to train and evaluate 3. Provides a baseline for comparing with more complex models later

The visualization serves as both a diagnostic tool (checking if splits make logical sense) and a communication aid (showing how different factors combine to influence weight predictions).

# Predict on train and test
dt_preds_train <- predict(dt_model, newdata = final_data[split, ])
dt_preds_test <- predict(dt_model, newdata = final_data[-split, ])

# Actual values
actual_train <- final_data$BMI[split]
actual_test <- final_data$BMI[-split]

# Calculate metrics
dt_result <- append_model_result("Decision Tree", train_label, dt_preds_train, test_label, dt_preds_test)

# Print summary
print(dt_result)
## # A tibble: 1 × 7
##   Model         RMSE_Train RMSE_Test MAE_Train MAE_Test R2_Train R2_Test
##   <chr>              <dbl>     <dbl>     <dbl>    <dbl>    <dbl>   <dbl>
## 1 Decision Tree       5.63      5.62      4.47     4.46    0.504   0.513
# Save for final comparison (append later)
final_model_results <- bind_rows(final_model_results, dt_result)

We used our simple decision tree to predict weights in two situations:

For people it already knew (training data) For new people it hadn’t seen before (test data) Then we calculated how far off our guesses were using RMSE (basically, how many pounds we’re off on average):

First we made predictions:

train_pred = guesses for our original group test_pred = guesses for the new group

Then we checked accuracy:

Squared all the differences between guesses and real weights Took the average and then the square root (that’s RMSE) This gives us one number showing typical error size

Finally we printed clean results:

Training error: Shows how well it learned the patterns Testing error: Shows how well it works on new people The printout gives us numbers like “12.34” meaning we’re typically about 12 pounds off in our weight guesses. Smaller numbers mean better predictions!

This helps us understand:

If our model is working properly Whether it’s guessing better for known vs new people How accurate we can expect it to be in real use

# Extract and format variable importance for Decision Tree
dt_importance <- tibble(
  Feature = names(dt_model$variable.importance),
  Importance = as.numeric(dt_model$variable.importance)
) %>%
  arrange(desc(Importance))

# View top features (e.g., top 10)
print(head(dt_importance, 10))
## # A tibble: 10 × 2
##    Feature                        Importance
##    <chr>                               <dbl>
##  1 family_history_with_overweight     24987.
##  2 FCVC                               13008.
##  3 CAEC                               10969.
##  4 Age                                 8078.
##  5 MTRANS                              3366.
##  6 TUE                                 2893.
##  7 Gender                              1114.
##  8 CH2O                                1112.
##  9 CALC                                1062.
## 10 FAF                                  196.

Understanding What Really Affects Weight Predictions

After building our weight-prediction model, we wanted to know which factors mattered most. Here’s how we checked:

We extracted the “importance scores” from our decision tree model These scores show how much each characteristic (like age or height) helped make accurate predictions The more a variable was used to split the data, the higher its score

We organized this information neatly in a table with:

Column 1: The factor name (e.g., “Height”, “Age”) Column 2: Its importance score (higher numbers = more important)

We used the kable() function to display this as a clean, professional-looking table Added a clear title explaining what the table shows Made it easy to compare which factors are most influential

This helps us answer questions like:

• What lifestyle factors most affect weight predictions? • Should we focus on collecting more detailed data about certain factors? • Do the important variables make logical sense?

Model 2: XgBoost (Carson)

############################
# XGBoost Tuning Functions
############################

tune_xgboost_param <- function(param_name, param_values, fixed_params) {
  results <- tibble(
    !!param_name := numeric(),
    RMSE = numeric(),
    MAE = numeric(),
    R2 = numeric()
  )
  
  for (val in param_values) {
    # Set up the dynamic parameter list
    params <- modifyList(fixed_params, setNames(list(val), param_name))
    
    # Extract all parameters with defaults for missing ones
    max_depth <- params$max_depth %||% 6
    eta <- params$eta %||% 0.3
    nrounds <- params$nrounds %||% 100
    subsample <- params$subsample %||% 1
    colsample_bytree <- params$colsample_bytree %||% 1
    min_child_weight <- params$min_child_weight %||% 1
    
    # Train model with all parameters
    model <- xgboost(
      data = train_matrix,
      label = train_label,
      objective = "reg:squarederror",
      max_depth = max_depth,
      eta = eta,
      nrounds = nrounds,
      subsample = subsample,
      colsample_bytree = colsample_bytree,
      min_child_weight = min_child_weight,
      verbose = 0
    )
    
    # Predict and evaluate
    preds <- predict(model, newdata = test_matrix)
    rmse <- sqrt(mean((preds - test_label)^2))
    mae <- mean(abs(preds - test_label))
    r2 <- 1 - sum((preds - test_label)^2) / sum((mean(test_label) - test_label)^2)
    
    # Append to results
    results <- add_row(results,
                       !!param_name := val,
                       RMSE = round(rmse, 3),
                       MAE = round(mae, 3),
                       R2 = round(r2, 3))
  }
  
  return(results)
}

# Helper function for NULL handling (equivalent to %||% in tidyverse)
"%||%" <- function(x, y) if (is.null(x)) y else x

# plot the results & and identify optimal level

plot_xgb_metric_curve <- function(results_df, hyperparam = "max_depth") {
  # Find optimal row based on lowest RMSE
  optimal_row <- results_df[which.min(results_df$RMSE), ]
  optimal_value <- optimal_row[[hyperparam]]

  # Reshape for plotting
  results_long <- results_df %>%
    pivot_longer(cols = c(RMSE, MAE), names_to = "Metric", values_to = "value")

  # Plot
  ggplot(results_long, aes(x = .data[[hyperparam]], y = value, color = Metric)) +
    geom_line(size = 1.2) +
    geom_point() +
    geom_vline(xintercept = optimal_value, linetype = "dashed", color = "red", size = 1) +
    annotate("text",
           x = optimal_value + 0.015,
           y = median(results_long$value) + .5,  # More stable than max()
           label = "Optimal",
           color = "red",
           angle = 90,
           vjust = -0.2,
           size = 4) +
    theme_minimal() +
    labs(
      title = paste("Model Performance by", gsub("_", " ", tools::toTitleCase(hyperparam))),
      x = tools::toTitleCase(gsub("_", " ", hyperparam)),
      y = "Metric Value"
    )
}
############################
# Depth Tuning (eta = 0.15, nrounds = 100)
############################

depth_results <- tune_xgboost_param("max_depth", seq(3, 12, by = 1),
                                  fixed_params = list(eta = 0.15, nrounds = 100))
depth_plot <- plot_xgb_metric_curve(depth_results, hyperparam = "max_depth")
## Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
## ℹ Please use `linewidth` instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
depth_results
depth_plot

  • Based on this we’ll fix the depth to 8
# ######################################
# Eta Tuning (max_depth = 8, nrounds = 100)
# ######################################

eta_results <- tune_xgboost_param("eta", 
                                  seq(0.05, 0.3, by = 0.025),
                                  fixed_params = list(max_depth = 8, nrounds = 100))

eta_plot <- plot_xgb_metric_curve(eta_results, hyperparam = "eta")

eta_results
eta_plot

  • Based on this we’ll fix the eta to 0.15
# ##############################################
# nrounds Tuning (max_depth = 8, eta = 0.15)
# ##############################################

nrounds_results <- tune_xgboost_param(
  "nrounds",
  seq(20, 300, by = 20),
  fixed_params = list(max_depth = 8, eta = 0.15)
)

nrounds_plot <- plot_xgb_metric_curve(nrounds_results, hyperparam = "nrounds")

nrounds_results
nrounds_plot

- we’ll set this to the minimum of 80

############################
# Subsample Tuning (max_depth = 8, eta = 0.15, nrounds = 80)
############################

subsample_results <- tune_xgboost_param(
  "subsample",
  seq(0.5, 1.0, by = 0.05),
  fixed_params = list(max_depth = 8, eta = 0.15, nrounds = 80)
)

subsample_plot <- plot_xgb_metric_curve(subsample_results, hyperparam = "subsample")

subsample_results
subsample_plot

############################
# Colsample_bytree Tuning (max_depth = 8, eta = 0.15, nrounds = 80, subsample = 0.7)
############################

# Use the optimal subsample value from previous tuning
optimal_subsample <- subsample_results[which.min(subsample_results$RMSE), "subsample"]

colsample_results <- tune_xgboost_param(
  "colsample_bytree",
  seq(0.5, 1, by = 0.05),
  fixed_params = list(max_depth = 8, eta = 0.15, nrounds = 80, subsample = optimal_subsample)
)

colsample_plot <- plot_xgb_metric_curve(colsample_results, hyperparam = "colsample_bytree")

colsample_results
colsample_plot

############################
# Min_child_weight Tuning (using all optimal parameters from previous tuning)
############################

# Get optimal colsample value
optimal_colsample <- colsample_results[which.min(colsample_results$RMSE), "colsample_bytree"]

min_child_results <- tune_xgboost_param(
  "min_child_weight",
  seq(1, 15, by = 1),
  fixed_params = list(
    max_depth = 8, 
    eta = 0.15, 
    nrounds = 80, 
    subsample = optimal_subsample,
    colsample_bytree = optimal_colsample
  )
)

min_child_plot <- plot_xgb_metric_curve(min_child_results, hyperparam = "min_child_weight")

min_child_results
min_child_plot

  • these last 3 hyper parameter had a lot of variation in their results

  • there is massive list of hyperparameters to tune, but i’m going to stop with these 6

############################
# Final Tuned XGBoost Model
############################

set.seed(4)

# Train final model with manually specified hyperparameters
final_model <- xgboost(
  data = train_matrix,
  label = train_label,
  objective = "reg:squarederror",
  booster = "gbtree",
  max_depth = 8,     
  eta = 0.15,          
  nrounds = 80,             
  subsample = 0.7,          
  colsample_bytree = 0.8,   
  min_child_weight = 10,     
  verbose = 0
)

# Evaluate XGBoost on both train and test sets
xgb_preds_train <- predict(final_model, newdata = train_matrix)
xgb_preds_test <- predict(final_model, newdata = test_matrix)

# Calculate metrics
xgb_results <- append_model_result("XGBoost (Fully Tuned)", train_label, xgb_preds_train, test_label, xgb_preds_test)

# Print and store
print(xgb_results)
## # A tibble: 1 × 7
##   Model                 RMSE_Train RMSE_Test MAE_Train MAE_Test R2_Train R2_Test
##   <chr>                      <dbl>     <dbl>     <dbl>    <dbl>    <dbl>   <dbl>
## 1 XGBoost (Fully Tuned)       1.55      2.78      1.03     1.95    0.963   0.881
final_model_results <- bind_rows(final_model_results, xgb_results)
  • XGBoost model significantly outperforms the linear model:

  • RMSE dropped from 7.59 → 2.775

  • MAE was also very low at ~1.9, suggesting consistent predictions

# Feature importance using final_model (Based on gain)
importance_matrix <- xgb.importance(model = final_model, feature_names = colnames(train_matrix))

# View as table
print(importance_matrix)
##                            Feature         Gain       Cover   Frequency
##  1: family_history_with_overweight 0.2317319372 0.036311285 0.027027027
##  2:                            Age 0.1478991272 0.217379167 0.187803188
##  3:                           FCVC 0.1161589515 0.120586196 0.112959113
##  4:                           CAEC 0.1123519260 0.077270439 0.042273042
##  5:                            TUE 0.0883558831 0.096562805 0.127165627
##  6:                            NCP 0.0810737564 0.083921494 0.085239085
##  7:                            FAF 0.0711779422 0.134785211 0.160429660
##  8:                         MTRANS 0.0463231355 0.035653456 0.025641026
##  9:                         Gender 0.0347175730 0.027308082 0.039501040
## 10:                           CH2O 0.0341372971 0.103555397 0.127512128
## 11:                           CALC 0.0245622328 0.027784749 0.035689536
## 12:                           FAVC 0.0066940218 0.015430032 0.018018018
## 13:                            SCC 0.0044059539 0.015985400 0.008662509
## 14:                          SMOKE 0.0004102622 0.007466288 0.002079002
# Optional: Plot top 15 features
xgb.plot.importance(
  importance_matrix,
  top_n = 15,
  measure = "Gain",
  rel_to_first = TRUE,
  xlab = "Relative Importance"
)

# Step 1: Prepare DMatrix for SHAP
dtrain <- xgb.DMatrix(data = train_matrix, label = train_label)

# Step 2: Compute SHAP values
shap_values <- shap.values(xgb_model = final_model, X_train = train_matrix)

# Step 3: Create SHAP summary
shap_summary <- shap_values$mean_shap_score
shap_importance <- data.frame(
  Feature = names(shap_summary),
  Mean_SHAP = shap_summary
) |>
  arrange(desc(Mean_SHAP))

# View top 10 SHAP features
head(shap_importance, 10)
  • Strong agreement across SHAP and Gain rankings suggests these top features are truly valuable (especially family history, FCVC, and Age).

  • SHAP provides extra context: it shows that even features like CH2O and Gender that aren’t frequently split on still influence predictions consistently.

  • Gain shows how much each split helps reduce error, so it’s more tree-structure specific.

  • SHAP captures the total effect on the model’s prediction output, even if the feature is only used in subtle ways.

final_model_results %>%
  arrange(RMSE_Test) %>%
  kable(caption = "Model Comparison: Sorted by Test RMSE")
Model Comparison: Sorted by Test RMSE
Model RMSE_Train RMSE_Test MAE_Train MAE_Test R2_Train R2_Test
XGBoost (Fully Tuned) 1.546 2.775 1.034 1.952 0.963 0.881
Decision Tree 5.634 5.615 4.469 4.461 0.504 0.513
Linear Regression 7.631 7.777 6.390 6.416 0.090 0.065
# --- Step 1: Rank features for each model ---

# XGBoost top 10 features
xgb_top <- xgb.importance(model = final_model, feature_names = colnames(train_matrix)) %>%
  slice_max(Gain, n = 10) %>%
  mutate(Rank = row_number()) %>%
  select(Rank, XGBoost = Feature)

# Decision Tree top 10 features (coerce to tibble explicitly)
dt_top <- tibble(
  Feature = names(dt_model$variable.importance),
  Importance = as.numeric(dt_model$variable.importance)
) %>%
  arrange(desc(Importance)) %>%
  dplyr::slice(1:10) %>%
  mutate(Rank = row_number()) %>%
  select(Rank, Decision_Tree = Feature)


# Linear Regression top 3 features based on t-stat
lm_summary <- summary(lm_model)
lr_top <- tibble(
  Feature = rownames(lm_summary$coefficients),
  T_stat = lm_summary$coefficients[, "t value"]
) %>%
  filter(Feature != "(Intercept)") %>%
  arrange(desc(abs(T_stat))) %>%
  mutate(Rank = row_number()) %>%
  dplyr::slice(1:3) %>%
  select(Rank, Linear_Regression = Feature)

# --- Step 2: Merge all rankings into a single table ---

# Create a full Rank index (1 to 10)
rank_frame <- tibble(Rank = 1:10)

# Join all ranking tables
feature_rankings <- rank_frame %>%
  left_join(xgb_top, by = "Rank") %>%
  left_join(dt_top, by = "Rank") %>%
  left_join(lr_top, by = "Rank")

# --- Step 3: Print clean table ---
library(knitr)
kable(feature_rankings, caption = "Top Feature Rankings Across Models")
Top Feature Rankings Across Models
Rank XGBoost Decision_Tree Linear_Regression
1 family_history_with_overweight family_history_with_overweight SCC
2 Age FCVC CALC
3 FCVC CAEC FAF
4 CAEC Age NA
5 TUE MTRANS NA
6 NCP TUE NA
7 FAF Gender NA
8 MTRANS CH2O NA
9 Gender CALC NA
10 CH2O FAF NA

Conclusion:

Methods - Describe building a shallow Decision Tree with depth = 3, reason for balance between interpretability and prediction. Results - Report Training RMSE, Testing RMSE, include the Decision Tree plot (Figure 1), and Variable Importance (Table 1). Discussion - Discuss Decision Trees being simple to interpret but sometimes underfitting complex patterns.

Comparison

1. Linear Regression

2. XGBoost

3. Decision Tree

For our case: