Florida Palmetto Species Classification with Binary Logistic Regression
Standard vs. Elastic Net Logistic Regression
Author
Jaden Orli
Published
June 5, 2025
I. Background
Florida hosts a diversity of native palm species including two species of Palmetto, Sabal etonia and Serenoa repens, that are endemic to the region. The Archbold Biological Station in south-central Florida encompasses an extensive area of Florida scrublands and is home to these endemic Palmetto species. This research institute has been collecting demographic data annually from 1981 to 1997, 2001 to 2017, and from 2022 to the present. Characteristics of these metrics including measurements of height (cm), canopy width (cm), canopy length (cm), and number of green leaves. These morphological features are measured over time to evaluate survival, growth, and potential species classification, providing a rich dataset for logistic regression modeling.
This analysis will develop and compare four binary logistic regression models (two standard logistic regressions and two elastic net regressions) to explore the feasibility of using the various parameters outlined above to correctly classify a plant as either Sabal etonia or Serenoa repens.
II. Load Libraries
First, load the necessary packages for this analysis:
Code
#this clears out the environmentrm(list =ls())#load necessary data packages##data managementlibrary(tidyverse)library(here)library(janitor)library(stringr)##data visualizationlibrary(kableExtra)library(calecopal)library(ggtext)library(ggbreak)library(htmltools)library(equatiomatic)##model constructionlibrary(tidymodels)##analysislibrary(yardstick)
III. Set Up
This section defines the necessary files and explores preliminary data visualizations:
We begin by loading the full palmetto dataset and performing initial cleaning and recoding steps. The raw species indicator is numerically coded (1 or 2), so we recode species to 0 for Sabal etonia and 1 for Serenoa repens to fit the binary regression setup. We also generate a human-readable name variable for labeling purposes.
#set the seed for reproducibilityset.seed(42)#read in the palmetto datapalmetto_full <-read_csv(here("data", "palmetto.csv"))#tidy the datapalmetto <- palmetto_full %>%select(plant, species, height, length, width, green_lvs) %>%mutate(species =if_else(species ==2, 0, 1)) %>%#reassign species 2 to be species 0mutate(name =case_when(species ==0~"sabal_etonia", #create a name column species ==1~"serenoa_repens")) %>%mutate(species =factor(species, #make species a class factor levels =c(0, 1),labels =c("Sabal etonia", "Serenoa repens"))) %>%mutate(name =factor(name, #make species a class factor levels =c("sabal_etonia", "serenoa_repens"),labels =c("Sabal etonia", "Serenoa repens")))
b) Color Palettes
Now we can define the color palettes to be used throughout this analysis
Code
#generate a palette from the calecopal arbutus color palettearbutus_palette <-cal_palette(name ="arbutus", n =5)#create a vector of selected hex codes from the arbutus_palettehex_codes <-c("#336886", "#B5C861", "#DCC27A", "#CB8573", "#976153")
i) Palmetto Species
From this palette we can assign each a color to each Palmetto species:
Sabal etonia
Serenoa repens
Code
#create a vector assigning a color to each of the species names species_colors <-c("sabal_etonia"="#DCC27A","Sabal etonia"="#DCC27A","serenoa_repens"="#336886","Serenoa repens"="#336886")
ii) Prediction Classification Types
We can also define the colors that will be used to identify if a model correctly (True) or incorrectly (False) predicted the actual species when trained on real observations.
Code
#create a vector assigning a color to classification typesclassification_colors <-c("True"="#B5C861","False"="#CB8573")
c) Exploratory Visualizations
To start our analysis process, we will begin by generating various graphics to explore the relationship between our variables of interest.
i) Data Wrangling
As always, this process starts with some data wrangling.
Code
#pivot longer so we can visualize each numeric variablepalmetto_long <- palmetto %>%pivot_longer(cols =c(height, length, width, green_lvs),names_to ="variable",values_to ="value") %>%select(-plant) %>%mutate(axis_labels =case_when(variable =="height"~"Maximum Height (cm)", variable =="length"~"Canopy Length (cm)", variable =="width"~"Canopy Width (cm)", variable =="green_lvs"~"Count of Green Leaves")) %>%mutate(axis_labels =factor(axis_labels))
ii) Compare Predictor Variables
Now that we have tided the data, we can create a violin plot to compare the distribution of each predictor variable between both Palmetto species. We then overlaid each plot with a boxplot to more easily visualize the summary statistics.
Code
#create a violin plot to visualize the variable (height, length, width, green_lvs) spread for each speciesviolin_plots <-ggplot(palmetto_long, aes(x = species, y = value, fill = name)) +geom_violin(trim =FALSE, alpha =0.7) +#add a violin plotscale_fill_manual(values = species_colors,labels =c("Sabal etonia", "Serenoa repens")) +geom_boxplot(width =0.1, fill ="white", alpha =0.7, outlier.shape =NA) +#add a boxplot for summary statsfacet_wrap(~axis_labels, #create a plot for each variablescales ="free_y") +labs(title ="Spread of Predictor Variables by Species:",subtitle =expression("<i>Serenoa repens</i> and <i>Sabal etonia</i>"),x ="Species",y ="Value",fill ="Palmetto Species:") +theme(plot.title =element_text(family ="Times New Roman",size =16,hjust =0.5,face ="bold"),plot.subtitle =element_markdown(family ="Times New Roman",size =12,hjust =0.5),axis.title =element_text(family ="Times New Roman",face ="bold",size =13),axis.text =element_text(family ="Times New Roman", size =10),axis.text.x =element_text(face ="italic"),strip.text =element_text(family ="Times New Roman",face ="bold",size =10),legend.position ="bottom",legend.title =element_text(family ="Times New Roman",face ="bold",size =12),legend.text =element_text(family ="Times New Roman",face ="italic",size =10),legend.box.background =element_rect(color ="black", size =1))#print the plotviolin_plots
When examining each individual variable, we see that canopy length, canopy width, and maximum height have a unimodal distribution indicating low variability within the species. However, upon further examination, it is apparent that they also have a similar median and spread when comparing across species. This indicates that these variables alone are not strong predictors of species. In contrast, the number of green leaves has a multimodal distribution with relatively high levels of spread between species. This metric appears to be the only selected predictor variable in which either species appears to have a slightly different median and spread across the sample.
iii) Visualize Predictor Distribution
To further examine this relationship, we can create a density plot to show the entire probability distribution for each variable and compare between species.
Code
#create density plots to visualize the variable (height, length, width, green_lvs) distributions for each speciesdensity_plots <-ggplot(palmetto_long, aes(x = value, fill = name)) +geom_density(alpha =0.5, linewidth =0.5) +facet_wrap(~axis_labels, scales ="free") +scale_fill_manual(values = species_colors,labels =c("Sabal etonia", "Serenoa repens")) +labs(title ="Density Distribution of Predictor Variables by Species",subtitle =expression("<i>Serenoa repens</i> and <i>Sabal etonia</i>"),x ="Value",y ="Density",fill ="Palmetto Species") +theme(plot.title =element_text(family ="Times New Roman",size =16,hjust =0.5,face ="bold"),plot.subtitle =element_markdown(family ="Times New Roman",size =12,hjust =0.5),axis.title =element_text(family ="Times New Roman",face ="bold",size =13),axis.text =element_text(family ="Times New Roman", size =8),strip.text =element_text(family ="Times New Roman",face ="bold",size =10),legend.position ="bottom",legend.title =element_text(family ="Times New Roman",face ="bold",size =12),legend.text =element_text(family ="Times New Roman",face ="italic",size =10),legend.box.background =element_rect(color ="black", size =1))#print the plotdensity_plots
When examining the three variable with a unimodal distribution (length, width, and height), it appears that canopy length is likely the strongest predictor of species with these plots having the least overlap between species. On the other hand, the maximum height variable is likely not a strong predictor of species since the plots align nearly perfectly when overlaid. Canopy width also appears to lack significant difference between species. However, out of all the variables, the count of green leaves shows the most distinct distributions across species suggesting it may be a significant factor in classification accuracy.
IV. Partition Data
Now that we understand the general characteristics of each predictor variable, we can partition the dataset into two dataframes; one to train the model and one to test the model. This ensures that model evaluation is conducted on previously unseen data.
a) Evaluate Full Dataset
First, we want to determine the relative proportion of each species in the full dataset because we will need to ensure that this class stratification is maintained in both the training data and the testing data when we perform the split.
Code
#create a dataframe with a summary of the proportion of each species for the full datasetpalmetto_full_sum <- palmetto %>%group_by(species) %>%#group the data by the species columnsummarize(Count =n()) %>%#determine the number of entries for each speciesungroup() %>%mutate(Proportion =round((Count /sum(Count)), 3)) %>%#calculate the proportion of each species select(species, Count, Proportion) %>%add_column(Dataset ="Full", .before ="species")
b) Split Dataset
To ensure that both species are proportionally represented in the training and testing datasets, we perform a stratified split using the initial_split() function. We allocate 80% of the data to model training and reserve 20% for testing. Stratification is done by species to preserve the class distribution across subsets.
Code
#tidy the dataframe before splittingpalmetto_presplit <- palmetto %>%select(-plant) #remove the unnecessary column#split the data into 80% for training/building the model and 20% for testing the modelpalmetto_split <-initial_split(palmetto_presplit,prop =0.80, #80% training datastrata = species) #stratified by species #create the training dataframe from the split palmetto_train <-training(palmetto_split)#create the testing dataframe from the split palmetto_test <-testing(palmetto_split)
c) Proportions Across Datasets
After splitting the data, we need to ensure that the species stratification was maintained in both the training data and the testing data.
i) Training Data
#create a dataframe with a summary of the proportion of each species for the training datasetpalmetto_train_sum <- palmetto_train %>%group_by(species) %>%#group the data by the species columnsummarize(Count =n()) %>%#determine the number of entries for each speciesungroup() %>%mutate(Proportion =round((Count /sum(Count)), 3)) %>%#calculate the proportion of each species select(species, Count, Proportion) %>%add_column(Dataset ="Training", .before ="species")
ii) Testing Data
#create a dataframe with a summary of the proportion of each species for the testing datasetpalmetto_test_sum <- palmetto_test %>%group_by(species) %>%#group the data by the species columnsummarize(Count =n()) %>%#determine the number of entries for each speciesungroup() %>%mutate(Proportion =round((Count /sum(Count)), 3)) %>%#calculate the proportion of each species select(species, Count, Proportion) %>%add_column(Dataset ="Testing", .before ="species")
iii) Comparison Summary
Code
#combine the results into a single tablecombined <-bind_rows(palmetto_full_sum, palmetto_train_sum, palmetto_test_sum)#create a kable from the results combined_kable <-kable(combined,col.names =c("Dataset", "Species", "Count", "Proportion"),kable_title = htmltools::tags$div(style ="text-align: center; font-size: 20px;", htmltools::tags$strong("Proportions of Species Across All Datasets")),escape =FALSE) %>%kable_styling(full_width =FALSE, font_size ="0.8em", position ="center") %>%column_spec(column =1, width ="8em", bold =TRUE) %>%column_spec(column =2, width ="8em", italic =TRUE) %>%column_spec(column =3:4, width ="6em") %>%row_spec(row =0, bold =TRUE, italic =FALSE, background ="rgba(174, 209, 200, 0.4)") %>%kable_classic(html_font ="Times New Roman") %>%collapse_rows(columns =1, valign ="middle") #print the tablecombined_kable
Dataset
Species
Count
Proportion
Full
Sabal etonia
6245
0.501
Serenoa repens
6215
0.499
Training
Sabal etonia
4996
0.501
Serenoa repens
4972
0.499
Testing
Sabal etonia
1249
0.501
Serenoa repens
1243
0.499
As shown in the table above, the proportions of Sabal etonia and Serenoa repens remain stable across all partitions. This confirms that stratification was successful and minimizes bias in model training and evaluation. After confirming that all dataframes have the same relative proportions of species, we can begin to construct the classification models.
V. Model Development
Now that we are ready to start building the models, we need to determine the approach best suited for this type of classification. SGiven our binary outcome variable (species classification), logistic regression is a natural modeling choice. It constrains predicted values to the [0, 1] interval, making it well-suited for estimating probabilities of categorical events. Using the logit function
\[
\begin{aligned}
\text{where:}\\\
p & = \text{probability of the event occurring} \\
1 - p & = \text{probability of the event not occurring} \\
\frac{p}{1 - p} & = \text{odds of the event occurring} \\
\ln\left(\frac{p}{1 - p}\right) & = \text{log-odds (logit) of the event occurring}
\end{aligned}
\]
as our link function to convert the probabilities into log-odds and map the predictions into probability space between [0, 1]. The logit function will linearize this nonlinear relationship, and we can use the log-odds to model the probability of an event occurring using a linear equation. To explore the impacts of several predictor variables, we will use the two formulas outlined below:
For each of these formulas, we evaluated the prediction ability of the binary logistic regression both without regularization (standard) and with regularization (elastic net). We will explore the framework of both models below:
\[
\begin{aligned}
\text{where:}\\\
x_j & = \text{predictor variable} \\
\beta_0 & = \text{regression intercept} \\
& = \text{log-odds of the event when all } x_j = 0 \\
\beta_j & = \text{regression coefficient} \\
& = \text{change in the log-odds for a one unit increase in } x_j
\end{aligned}
\]
This regression predicts the probability of an event occurring by modeling the log-odds as a linear function of the inputted predictor variables. However, the standard binary logistic regression does not allow for regularization, which may make it more prone to overfitting. While our dataset has a relatively limited number of predictor variables (only considering height, canopy length, canopy width, and number of green leaves), it is still important to examine potential collinearity issues between these demographic characteristics. The coefficients \(\beta_j\) quantify how each morphological trait contributes to the likelihood of classifying a palmetto as Serenoa repens. Positive values increase this likelihood; negative values suggest an association with Sabal etonia.
2. Elastic Net Logistic Regression
Therefore, we will also explore an alternative model framework
that builds on the standard regression to allow for regularization through the additional inclusion of penalty terms (L1 and L2). The first penalty term is the L1 Penalty (Lasso Regression)
\[
\lambda \sum |\beta_j|
\]
which automates feature selection by forcing some regression coefficients to exactly zero to reduce overfitting by removing predictors. The second penalty, the L2 Penalty (Ridge Regression),
\[
\lambda \sum \beta_j^2
\]
is useful for highly correlated predictors and shrinks the regression coefficients, but does not completely eliminate them from consideration. The elastic net model
allows for a mixture of both L1 and L2 penalty terms. We will tune these hyperparmeters, the penalty strength (λ) and mixture composition (α), to address any potential collinearity.
c) Model Framework
Now we can establish the framework for both the standard logistic regression and the elastic net logistic regression.
1. Standard Logistic Regression
We define the standard logistic regression model using the logistic_reg() function with the "glm" engine, which uses maximum likelihood estimation.
#construct a standard logistic regression modelstandard_model <-logistic_reg() %>%set_engine("glm") #use a generalized linear model to fit the regression
Similar to the chunk above, we will outline a logistic regression model, but here we will allow for regularization and hyperparameter tuning.
2. Elastic Net Logistic Regression
The elastic net model allows us to tune both the penalty strength and the balance between L1 and L2 regularization. By setting both penalty and mixture to tune(), we can optimize these hyperparameters through cross-validation.
#construct an elastic net logistic regression modelelastic_model <-logistic_reg(penalty =tune(), #allow hyperparameter tuningmixture =tune()) %>%#determine optimal mixture between lasso and ridgeset_engine("glmnet")
d) Hyperparameter Optimization
Constructing the elastic net logistic regression requires additional preprocessing steps to determine the optimal penalty (λ) and mixture (α) values to be used when we regularize the model.
i) Potential Hyperparameter Values
First, we define a tuning grid of 100 (10×10) combinations of penalty strength and mixing parameter. This grid will be used during cross-validation to identify the combination that yields the best classification performance.
#define a grid of penalty (λ) and mixture (α) valueselastic_grid <-grid_regular(penalty(range =c(0.0001, 1)), #regularization strengthmixture(range =c(0, 1)), #0 = pure ridge, 1 = pure lassolevels =10) #number of levels to explore
ii) Ten Fold Cross Validation
Here we will create ten folds to use in our hyperparameter tuning. To minimize overfitting, we will perform a ten fold cross validation to assess how well the model generalizes across different subsets of the data.
#create cross-validation folds for hyperparameter tuningcv_folds <-vfold_cv(palmetto_train, v =10, #use 10 folds strata = species)
VI. Model Construction
With the formulas defined, we now implement logistic regression using the tidymodels framework. This involves creating a “recipe” that specifies preprocessing steps and a “workflow” that combines the model with its recipe for training and evaluation.
a) Model Recipe
1. Standard Logistic Regression
We define two recipes for the standard logistic regression models. These recipes specify the outcome (name) and the predictor variables. No additional preprocessing is needed because the "glm" engine handles raw numeric data.
A1: Standard Model One
#define the recipe for the standard model onestandard_recipe1 <-recipe(name ~ height + length + width + green_lvs,data = palmetto_train)
B1: Standard Model Two
#define the recipe for the standard modelstandard_recipe2 <-recipe(name ~ height + width + green_lvs,data = palmetto_train)
2. Elastic Net Logistic Regression
Elastic net regression is sensitive to scale, so numeric predictors must be normalized. Additionally, variables with zero variance provide no predictive value and are removed. These preprocessing steps are defined in the recipe() and applied before tuning.
A2: Elastic Net Model One
#define the recipe for the elastic modelelastic_recipe1 <-recipe(name ~ height + length + width + green_lvs,data = palmetto_train) %>%step_zv(all_numeric(), -all_outcomes()) %>%#eliminate predictors that have zero variancestep_naomit(all_numeric()) %>%#remove rows with missing numeric valuesstep_normalize(all_numeric(), -all_outcomes()) #normalizes numerical variables to have mean = 0 and standard deviation = 1
B2: Elastic Net Model Two
#define the recipe for the elastic modelelastic_recipe2 <-recipe(name ~ height + width + green_lvs,data = palmetto_train) %>%step_zv(all_numeric(), -all_outcomes()) %>%#eliminate predictors that have zero variancestep_naomit(all_numeric()) %>%#remove rows with missing numeric valuesstep_normalize(all_numeric(), -all_outcomes()) #normalizes numerical variables to have mean = 0 and standard deviation = 1
b) Model Workflow
We create workflows that bundle the model structure and preprocessing steps into a single object. This modular setup allows easy training, prediction, and cross-validation.
1. Standard Logistic Regression
A1: Standard Model One
#create a workflow to bundle the recipe (data transformation) and the modelstandard_wf1 <-workflow() %>%add_recipe(standard_recipe1) %>%add_model(standard_model)
B1: Standard Model Two
#create a workflow to bundle the recipe (data transformation) and the modelstandard_wf2 <-workflow() %>%add_recipe(standard_recipe2) %>%add_model(standard_model)
2. Elastic Net Logistic Regression
The elastic net workflows pair our preprocessing pipelines with the regularized logistic regression model. Because we defined both penalty and mixture as tuning parameters, these workflows are ready for grid search cross-validation.
A2: Elastic Net Model One
#create a workflow to bundle the recipe (data transformation) and the modelelastic_wf1 <-workflow() %>%add_recipe(elastic_recipe1) %>%add_model(elastic_model)
B2: Elastic Net Model Two
#create a workflow to bundle the recipe (data transformation) and the modelelastic_wf2 <-workflow() %>%add_recipe(elastic_recipe2) %>%add_model(elastic_model)
c) Hyperparameter Optimization
Before continuing, we need to determine the optimal values for the penalty (λ) and mixture (α) terms in our elastic net regression.
i) Tune Hyperparameters
We use ten-fold cross-validation to evaluate combinations of penalty and mixture values. The elastic net is sensitive to the strength and balance of regularization. The tune_grid() function evaluates each parameter combination and returns performance metrics for each fold. We focus on both ROC AUC and accuracy as evaluation criteria. We begin this process by using a ten fold cross validation to resample across the grid of hyperparameter values generated above.
A2: Elastic Net Model One
#tune the model using cross-validationelastic_tune_results1 <- elastic_wf1 %>%tune_grid(resamples = cv_folds,grid = elastic_grid,metrics =metric_set(roc_auc, accuracy)) #evaluation metrics
B2: Elastic Net Model Two
#tune the model using cross-validationelastic_tune_results2 <- elastic_wf2 %>%tune_grid(resamples = cv_folds,grid = elastic_grid,metrics =metric_set(roc_auc, accuracy)) #evaluation metrics
ii) Select Optimal Values
The optimal parameter set is selected based on the maximum ROC AUC. This prioritizes the model’s ability to distinguish between the two species classes. The selected values of λ and α are then passed into the finalized model workflow.
A2: Elastic Net Model One
#select the best penalty and mixture values based on AUCbest_params1 <- elastic_tune_results1 %>%select_best(metric ="roc_auc")#print the resultsbest_params1 %>%rename(Penalty = penalty,Mixture = mixture) %>%select(-.config)
#select the best penalty and mixture values based on AUCbest_params2 <- elastic_tune_results2 %>%select_best(metric ="roc_auc")#print the resultsbest_params2 %>%rename(Penalty = penalty,Mixture = mixture) %>%select(-.config)
After identifying these optimal hyperparameter values, we can finalize the model workflow for the elastic net regressions. We finalize each workflow by incorporating the optimal penalty and mixture values. This ensures the model is trained using the best-found regularization configuration.
A2: Elastic Net Model One
#finalize workflow using the best hyperparametersfinal_elastic_wf1 <- elastic_wf1 %>%finalize_workflow(best_params1)
B2: Elastic Net Model Two
#finalize workflow using the best hyperparametersfinal_elastic_wf2 <- elastic_wf2 %>%finalize_workflow(best_params2)
VII. Model Predictions
With each model finalized and optimized, we now fit them to the training data and evaluate their performance on the unseen testing set. This allows us to assess how well each approach generalizes to new data and compare the predictive power of the full and reduced formulas across both standard and regularized models.
a) Model Training
1. Standard Logistic Regression
We begin by training the standard logistic regression models using the training data.
A1: Standard Model One
This model includes all four predictor variables (height, length, width, and green leaves) and uses maximum likelihood estimation with no regularization.
#fit the standard logistic regression modelstandard_fit1 <- standard_wf1 %>%fit(palmetto_train) #fit the model with the training data
B1: Standard Model Two
The second standard logistic regression model is fit using a reduced set of predictors, excluding canopy length. This allows us to evaluate whether a simpler model can achieve comparable classification performance.
#fit the standard logistic regression modelstandard_fit2 <- standard_wf2 %>%fit(palmetto_train) #fit the model with the training data
2. Elastic Net Logistic Regression
A2: Elastic Net Model One
We now train the full elastic net logistic regression model using the previously selected optimal penalty and mixture values. This model includes all predictors but applies regularization to reduce overfitting and handle any multicollinearity between features.
#fit the finalized model on the full training datasetelastic_fit1 <- final_elastic_wf1 %>%fit(palmetto_train)
B2: Elastic Net Model Two
The final model is an elastic net version of the reduced predictor set. By combining simplicity (fewer variables) and regularization, this model may offer strong generalization with improved interpretability.
#fit the finalized model on the full training datasetelastic_fit2 <- final_elastic_wf2 %>%fit(palmetto_train)
The models are now trained on the 80% training data. Standard models are fit using maximum likelihood, while elastic net models use the optimal penalty and mixture values previously determined through tuning.
b) Model Testing
Both the predicted class label and associated class probabilities are retained for evaluation. These outputs are used to calculate accuracy, visualize probability distributions, and construct confusion matrices.
1. Standard Logistic Regression
A1: Standard Model One
The full standard model is used to predict class labels and class probabilities for each plant in the test set. These predictions will later be used to construct a confusion matrix and compute metrics like accuracy, precision, and ROC AUC.
This reduced version of the standard logistic regression model generates both predicted class labels and species probabilities using the smaller set of predictors. These predictions allow us to assess whether excluding canopy length affects the model’s ability to distinguish between species.
The full elastic net model predictions are now generated for the test dataset. This model includes all four predictors and applies a combination of L1 and L2 regularization. The predicted probabilities are especially useful for comparing model confidence and calibration relative to the standard logistic model.
Finally, the reduced elastic net model is used to predict class labels and probabilities. This model uses fewer variables and applies regularization, making it potentially more generalizable and interpretable. The test set predictions will be used to directly compare all four models in terms of classification accuracy and probability confidence.
At this stage, all four logistic regression models have been trained on the full training dataset and used to generate predictions on the reamining test dataset. Each model produces both a predicted class label and a predicted probability for each species, enabling us to compare not only their overall classification accuracy, but also their confidence and calibration. In the next section, we use these predictions to construct confusion matrices and calculate classification metrics that will help us understand how each model performs — both overall and in distinguishing between Sabal etonia and Serenoa repens. These results will inform which model offers the most reliable and interpretable classification tool for this dataset.
VIII. Model Performance Evaluation
Finally, we can begin to evaluate and compare the prediction performance of each model. Since we have two options for classification (either Sabal etonia or Serenoa repens), we can identify Sabal etonia as the positive class and Serenoa repens as the negative class. Therefore when we refer to a positive classification, this means that we are talking about Sabal etonia and we will refer to the Serenoa repens as a negative classification. Using this terminology, we can categorize the outcomes into four possibilities:
True Positive (TP):
the model correctly predicts a positive class when the actual class is positive
ex) the model correctly predicts the species to be Sabal etonia and the actual species is Sabal etonia
True Negative (TN):
the model correctly predicts a negative class when the actual class is negative
ex) the model incorrectly predicts the species to be Serenoa repens when the actual species is Serenoa repens
False Positive (FP) - Type One Error:
the model incorrectly predicts a positive class when the actual class is negative
ex) the model incorrectly predicts the species to be Sabal etonia when the actual species is Serenoa repens
False Negative (FN) - Type Two Error:
the model incorrectly predicts a negative class when the actual class is positive
ex) the model incorrectly predicts the species to be Serenoa repens when the actual species is Sabal etonia
a) Repeated Cross Validation
We will start by calculating a variety of metrics, including accuracy, precision, sensitivity, specificity, and ROC AUC (Receiver Operating Characteristic Area Under the Curve), for each model. These capture not just overall accuracy but also class-specific performance, which is particularly important for detecting imbalances or biases between species. These metrics are briefly summarized below:
a measure of how many predicted positives were actually positive
helps determine that the model is not biased towards one class (species) over another
\[
Precision = (\frac{TP}{TP + FP})
\]
Sensitivity (Recall):
a measure of how many actual positives were correctly predicted
\[
Sensitivity = (\frac{TP}{TP + FN})
\]
Specificity:
a measure of how many actual negatives were correctly predicted
\[
Specificity = (\frac{TN}{TN + FP})
\]
ROC AUC:
area under the receiver operator curve
a measure of overall classification performance
useful metric for cross-model comparisons
\[
\begin{aligned}
AUC &= 1.0 \to \text{perfect classifier} \\
AUC &> 0.8 \to \text{strong classificer} \\
AUC &\sim 0.5 \to \text{random guessing}
\end{aligned}
\] Among these, ROC AUC (Receiver Operating Characteristic Area Under the Curve) is particularly valuable. It evaluates a model’s ability to distinguish between the two classes across all possible classification thresholds, rather than being dependent on a fixed 0.5 cutoff. AUC balances the true positive rate and false positive rate, making it a robust and threshold-independent metric. For this reason, we prioritize ROC AUC when selecting the best hyperparameters for elastic net models.
To evaluate the generalization ability of each model, we apply repeated ten-fold cross-validation. This approach divides the training dataset into ten subsets, fits the model on nine, and validates on the remaining one — repeating this process ten times to reduce variability in performance estimates. By calculating the average of multiple classification metrics across all resamples, we obtain robust summaries of model performance for comparison.
Code
#perform a 10-fold cross validation to use to fit the model across ten different training/testing data splitsfolds <-vfold_cv(palmetto_train, v =10, repeats =10,strata = name)
This next group fits and evaluates all four models using fit_resamples().
1. Model One
A1: Standard Model One
The full standard logistic regression model is evaluated across all cross-validation splits. We collect multiple classification metrics (including accuracy, sensitivity, specificity, precision, and ROC AUC) to build a comprehensive understanding of model behavior.
#fit the model using the 10 fold cross validation standard_folds1 <- standard_fit1 %>%fit_resamples(resamples = folds,metrics =metric_set(accuracy, precision, sensitivity, specificity, roc_auc),control =control_resamples(save_pred =TRUE)) #collect the metrics from the cross fold validationstandard_cv1 <- standard_folds1 %>%collect_metrics()
B1: Elastic Net Model One
This code fits the full elastic net model across all resampling folds. Since this model includes regularization, we are interested to see whether performance improves or stabilizes relative to the unpenalized standard model.
#fit the model using the 10 fold cross validation elastic_folds1 <- elastic_fit1 %>%fit_resamples(resamples = folds,metrics =metric_set(accuracy, precision, sensitivity, specificity, roc_auc),control =control_resamples(save_pred =TRUE))#collect the metrics from the cross fold validationelastic_cv1 <- elastic_folds1 %>%collect_metrics()
2. Model Two
A2: Standard Model Two
The reduced standard logistic regression model removes canopy length as a predictor. This model may offer improved interpretability with minimal loss in performance, especially if the excluded variable contributes little predictive power.
#fit the model using the 10 fold cross validation standard_folds2 <- standard_fit2 %>%fit_resamples(resamples = folds,metrics =metric_set(accuracy, precision, sensitivity, specificity, roc_auc),control =control_resamples(save_pred =TRUE)) #collect the metrics from the cross fold validationstandard_cv2 <- standard_folds2 %>%collect_metrics()
B2: Elastic Net Model Two
The final model — a reduced elastic net logistic regression — combines regularization with a simpler predictor set. This model may generalize better by reducing overfitting, especially if there is multicollinearity in the full model.
#fit the model using the 10 fold cross validation elastic_folds2 <- elastic_fit2 %>%fit_resamples(resamples = folds,metrics =metric_set(accuracy, precision, sensitivity, specificity, roc_auc),control =control_resamples(save_pred =TRUE))#collect the metrics from the cross fold validationelastic_cv2 <- elastic_folds2 %>%collect_metrics()
b) Compare Mean Model Performance Metrics
Now that we have fitted each model to ten different data splits and calculated the metrics for each fold, we can calculate the mean value for each metric and compare the metrics across all models.
i) Calculate Mean Metrics
We now summarize the average performance metrics across all cross-validation folds for each of the four models. These metrics are rounded to two decimal places and scaled to percentages for readability. This step makes it easier to compare models side-by-side using the same evaluation criteria.
After calculating average performance for each model, we combine all metrics into one comparison table. We then identify the highest-performing model for each metric and display it in the “Recommendation” column. This allows us to quickly assess which model consistently outperforms others across evaluation criteria.
Code
#combine the tables and compare the metricscombined_metrics <- standard_metrics_mean1 %>%left_join(standard_metrics_mean2 %>%select(Metric, Standard2), by ="Metric") %>%left_join(elastic_metrics_mean1 %>%select(Metric, Elastic1), by ="Metric") %>%left_join(elastic_metrics_mean2 %>%select(Metric, Elastic2), by ="Metric") %>%pivot_longer(cols =c("Standard1", "Standard2", "Elastic1", "Elastic2"),names_to ="Model",values_to ="Prediction") %>%group_by(Metric) %>%mutate(Recommendation =ifelse(Prediction ==max(Prediction, na.rm =TRUE), Model, NA)) %>%mutate(Recommendation =first(na.omit(Recommendation))) %>%ungroup() %>%pivot_wider(names_from ="Model",values_from ="Prediction") %>%relocate(Recommendation, .after ="Elastic2")#create a kable from the results combined_metrics_kable <-kable(combined_metrics,col.names =c("Metric", "Standard One", "Standard Two", "Elastic One", "Elastic Two", "Recommendation"),kable_title = htmltools::tags$div(style ="text-align: center; font-size: 20px;", htmltools::tags$strong("Comparison of Model Performance Metrics")),escape =FALSE) %>%kable_styling(full_width =FALSE, font_size ="0.8rem", position ="center") %>%row_spec(row =0, bold =TRUE, background ="rgba(174, 209, 200, 0.4)") %>%kable_classic(html_font ="Times New Roman")#print the tablecombined_metrics_kable
Metric
Standard One
Standard Two
Elastic One
Elastic Two
Recommendation
accuracy
91.62
89.86
89.97
85.73
Standard1
precision
91.01
89.10
86.17
80.08
Standard1
roc_auc
97.24
96.31
97.04
93.96
Standard1
sensitivity
92.46
90.93
95.34
95.30
Elastic1
specificity
90.78
88.78
84.56
76.09
Standard1
Based on the results of our evaluation, it appears that the best model is Standard1 (A1: Standard Logistic Model One). This model performed the best across the majority (4/5) of the selected metrics including accuracy, precision, ROC AUC, and specificity. It was only outperformed by the two elastic net models in regards to sensitivity. This means that the elastic net logistic regressions are better at correctly identifying Serenao repens than the standard logistic regressions. But overall, it appears that the inclusion of canopy length is an important metric for distinguishing between species. Therefore, we will continue our evaluation with only model A1 and A2 since they include this variable. Overall, the results support the conclusion that a standard logistic regression model using all four predictor variables provides the best balance of accuracy and interpretability for this dataset.
IX. Model Selection
Given the consistent performance of the full-model formulations in earlier evaluations, we now focus our attention on A1 and A2 - the standard and elastic net logistic regression models that use all available predictors. This section compares these two models in more detail, with an emphasis on predicted probabilities and visual interpretation.
To finalize the selected models, we use the full training-test split (palmetto_split) and apply the last_fit() function. This trains each model on the training data and evaluates it on the held-out test set - producing performance estimates and predictions that can now be analyzed further.
A1: Standard Model One
Code
#train the model on all the datafinal_standard1 <- standard_wf1 %>%last_fit(palmetto_split)
B1: Elastic Net Model One
Code
#train the model on all the datafinal_elastic1 <- final_elastic_wf1 %>%last_fit(palmetto_split)
b) Prediction Probability Distributions
Now we need to determine which of the two remaining models (A1 or B1) is a better classifier. We can start by visualizing the probability distribution of the predictions for both models and compare their ability to classify one species over another. This helps us understand how each model behaves in terms of prediction confidence and helps us assess calibration: are the models confident and well-separated in their predictions, or are they uncertain and overlapping?
i) Extract Prediction Probabilities
For each model, we collect the predicted probabilities from the final fit. We restructure the data so that each row represents one probability (for either Sabal etonia or Serenoa repens) for a given observation. This format allows us to facet the distributions by species and model method in the next step.
Then we can finish preparing the data for visualization:
Code
#create a table with the combined prediction probabilitiescombined_preds <-bind_rows(standard_preds, elastic_preds) #define facet labels manually as a named vectorspecies_labels <-c("etonia_prob"="<i>Sabal etonia</i> Probability","repens_prob"="<i>Serenoa repens</i> Probability")
iii) Visualize Probability Distributions
Finally, we can plot the probability distributions of model predicitions for both species of Palmetto. In this plot, the color is used to identify the true species with the TRUESabal etonia in yellow and the TRUESerenoa repens in blue.
Sabal etonia
Serenoa repens
The plot below visualizes the predicted probability distributions for each species across the two top models. A well-performing classifier should produce a bimodal or polarized distribution, with most probabilities clustered near 0 or 1, indicating confident predictions.
Code
#plot the probability distributionsprobability_plot <-ggplot(combined_preds, aes(x = probability, fill = actual)) +geom_density(alpha =0.6, linewidth =0.5) +scale_y_continuous(position ="right") +facet_grid(method ~ species_prob, scales ="fixed", space ="fixed", switch ="y",labeller =labeller(species_prob = species_labels,method =c("elastic_net"="Elastic Net","standard"="Standard"))) +scale_fill_manual(values = species_colors,labels =c("Sabal etonia", "Serenoa repens")) +labs(title ="Probability Distributions for Predictions",subtitle ="Model One",x ="Predicted Probability",y ="Density",fill ="True Species") +theme(plot.title =element_text(family ="Times New Roman",size =16,hjust =0.5,face ="bold"),plot.subtitle =element_text(family ="Times New Roman",size =12,hjust =0.5),axis.title =element_text(family ="Times New Roman",face ="bold",size =13),axis.text =element_text(family ="Times New Roman", size =10),strip.text =element_markdown(family ="Times New Roman",face ="bold",size =11),legend.position ="bottom",legend.title =element_text(family ="Times New Roman",face ="bold",size =10),legend.text =element_text(family ="Times New Roman",size =10,face ="italic"),legend.box.background =element_rect(color ="black", size =1))#print the plotprobability_plot
This visualization compares the predicted probability distributions for each species under the two top-performing models. Each row represents a model (Standard or Elastic Net), and each column represents the predicted probability for a given species (Sabal etonia on the left, Serenoa repens on the right). The colors indicate the true species identity, with gold for Sabal etonia and blue for Serenoa repens.
In the tandard logistic regression model (bottom row), we observe highly polarized distributions: the model confidently assigns probabilities near 0 or 1, showing that it makes predictions with strong conviction. The clear separation between species suggests that the model distinguishes the two groups well.
In contrast, the elastic net model (top row) produces tighter, unimodal distributions centered around moderate probability values (~0.45–0.65). This indicates that the model is more conservative and less confident in its predictions. This reduced confidence is likely a result of the regularization effect, which shrinks coefficients and reduces extreme outputs. Importantly, the overlap between species is more pronounced, which could contribute to slightly lower classification metrics for this model.
Overall, while both models distinguish between species, the standard model makes more decisive predictions, whereas the elastic net model favors moderation and smoothness. This reflects a fundamental trade-off between model certainty and model robustness.
c) Parameterized Equations
After training the model on the entire dataset, we can extract the final values (the regression coefficient - β) and construct the final parameterized equations for these two models (A1 and B1).
A1: Standard Model One
For the standard logistic regression model, we extract the fitted parameters and convert the log-odds coefficients into interpretable quantities: odds and probabilities. Odds reflect how the odds of classification shift with each unit increase in the predictor, while the corresponding probability gives a sense of how likely classification into a given species becomes. We round each value for clarity and drop the intercept for ease of interpretation.
i) Extract Model Parameters
Code
#extract the fitted parameters standard_parms1 <- final_standard1 %>%extract_fit_parsnip() #tidy up the dataframe with the parameters standard_parms_clean <- standard_parms1 %>%tidy() %>%mutate(odds =exp(estimate), #convert the log-odds to oddsprob = odds/(1+ odds)) %>%#convert the odds to probabilitiesmutate(estimate =signif(estimate, 2), #keep 2 significant digitsstd_error = scales::scientific(std.error, 3),statistic =signif(statistic, 4),p_value = scales::scientific(p.value, 3),odds =signif(odds, 3),prob =signif(prob, 4)) %>%filter(term !="(Intercept)") %>%select(term, estimate, std_error, statistic, p_value, odds, prob)#create a kable standard_parms_kable <-kable(standard_parms_clean,col.names =c("Term", "Coeffecient (β)", "Standard Erorr (SE)", "Statistic", "p-value", "Odds", "Probability"),kable_title = htmltools::tags$div(style ="text-align: center; font-size: 20px;", htmltools::tags$strong("A1: Standard Logistic Regression Model One")),escape =FALSE) %>%kable_styling(full_width =FALSE, font_size ="0.8rem", position ="center") %>%row_spec(row =0, bold =TRUE, background ="rgba(174, 209, 200, 0.4)") %>%row_spec(row =0:4, align ="center") %>%kable_classic(html_font ="Times New Roman") #print the tablestandard_parms_kable
Term
Coeffecient (β)
Standard Erorr (SE)
Statistic
p-value
Odds
Probability
height
0.030
2.60e-03
11.37
5.93e-30
1.030
0.5074
length
-0.047
2.12e-03
-22.14
1.33e-108
0.954
0.4882
width
-0.039
2.34e-03
-16.87
7.87e-64
0.961
0.4901
green_lvs
1.900
4.37e-02
43.98
0.00e+00
6.840
0.8725
ii) Print Final Equation
Below is the parameterized equation for the standard logistic regression model. This equation represents the log-odds of classifying a plant as Serenoa repens as a function of the four predictor variables. The magnitude and sign of each coefficient indicate the direction and strength of each variable’s contribution to species classification.
Code
#extract the model (engine) from the final workflow standard_eq <- standard_parms1 %>%extract_fit_engine()#print the parameterized equation from elastic net model oneextract_eq(standard_eq, use_coefs =TRUE, wrap =TRUE)
For the elastic net model, we extract the coefficients associated with the best-performing penalty value identified during tuning. Like before, we convert these to odds and probabilities for interpretability. Regularization can shrink coefficients toward zero, so variables with minimal influence are likely excluded in this process.
i) Extract Model Parameters
To better understand the internal workings of the elastic net model, we extract and interpret the fitted coefficients corresponding to the best-performing penalty value identified during hyperparameter tuning. Like in the standard logistic regression case, we transform each log-odds coefficient into interpretable odds and probabilities, helping us understand how each predictor influences classification.
Because elastic net applies both L1 (lasso) and L2 (ridge) regularization, this model has the unique ability to shrink unimportant coefficients toward zero while retaining others which can potentially resulting in a more parsimonious model.
Code
#extract the workflow from the final fitted model elastic_wk_final <- final_elastic1 %>%extract_workflow()#extract the fitted parameters elastic_parms1 <- elastic_wk_final %>%extract_fit_parsnip() #extract coefficients and filter for the best penaltyelastic_params_clean <- elastic_parms1 %>%tidy() %>%filter(penalty == best_params1$penalty) %>%#keep only best penalty coefficientsmutate(odds =exp(estimate), #convert the log-odds to oddsprob = odds / (1+ odds)) %>%#convert the odds to probabilitiesmutate(estimate =signif(estimate, 2), #keep 2 significant digitsodds =signif(odds, 3),prob =signif(prob, 4)) %>%filter(term !="(Intercept)") %>%select(term, estimate, penalty, odds, prob)#create a kable elastic_parms_kable <-kable(elastic_params_clean,col.names =c("Term", "Coeffecient (β)", "Penalty", "Odds", "Probability"),kable_title = htmltools::tags$div(style ="text-align: center; font-size: 20px;", htmltools::tags$strong("A2: Elastic Net Regression Model One")),escape =FALSE) %>%kable_styling(full_width =FALSE, font_size ="0.8rem", position ="center") %>%row_spec(row =0, bold =TRUE, background ="rgba(174, 209, 200, 0.4)") %>%row_spec(row =0:4, align ="center") %>%kable_classic(html_font ="Times New Roman") #print elastic_parms1_kable tableelastic_parms_kable
Term
Coeffecient (β)
Penalty
Odds
Probability
height
0.013
1.00023
1.010
0.5033
length
-0.110
1.00023
0.900
0.4737
width
-0.046
1.00023
0.955
0.4884
green_lvs
0.260
1.00023
1.300
0.5657
ii) Print Final Equation
Code
#extract the model (engine) from the final workflow elastic_glm <- elastic_params_clean %>%extract_fit_engine()#print the parameterized equation from elastic net model oneextract_eq(elastic_glm, use_coefs =TRUE, wrap =TRUE)
After evaluating both models, the standard logistic regression model (A1) was selected over the elastic net (B1) for final classification. While the elastic net model is valuable for reducing model complexity and minimizing overfitting through regularization, it tended to produce more conservative and overlapping probability estimates. In contrast, the standard logistic regression model consistently assigned predictions with high certainty (closer to 0 or 1), yielding clearer species separations. It also outperformed the elastic net model across several key evaluation metrics, including accuracy, sensitivity, and specificity. Given these results, and the fact that all four predictors were retained and meaningful in the standard model, it was deemed the more effective and interpretable approach for this classification task.
d) Final Model Selection
To better understand how the final model performs across species classifications, we construct a confusion matrix. Each cell represents one of the four possible prediction outcomes: true positive, true negative, false positive, and false negative. This structure enables a clearer visual interpretation of model strengths and weaknesses in classifying Sabal etonia and Serenoa repens.
i) Data Wrangling
Code
#create a confusion matrix to determine the classification accuracyfinal_cm <- final_standard1 %>%collect_predictions() %>%conf_mat(truth = name, estimate = .pred_class) %>%tidy() %>%mutate(Actual =case_when(name =="cell_1_1"| name =="cell_1_2"~"Sabal etonia", name =="cell_2_1"| name =="cell_2_2"~"Serenoa repens"),Prediction =case_when(name =="cell_1_1"| name =="cell_2_1"~"Sabal etonia", name =="cell_1_2"| name =="cell_2_2"~"Serenoa repens"),classification =case_when(name =="cell_1_1"~"True Negative", #correctly classified etonia name =="cell_2_2"~"True Positive", #correctly classified repens name =="cell_1_2"~"False Positive", #misclassified etonia as repens name =="cell_2_1"~"False Negative")) %>%#misclassified repens as etoniamutate(error_color =case_when(name =="cell_1_1"| name =="cell_2_2"~"True", name =="cell_2_1"| name =="cell_1_2"~"False"))
ii) Confusion Matrix
The confusion matrix highlights how many plants were correctly and incorrectly classified by the final model. Both species show strong classification performance, with few false positives or false negatives. This supports the decision to select the standard logistic regression model as the final classifier since it performs consistently well across metrics and confidently distinguishes between the two Palmetto species.
Code
#create a heatmap to visualize the classification accuracyfinal_cm_plot <-ggplot(final_cm, aes(x = Prediction, y = Actual, fill = error_color)) +geom_tile(color ="white") +geom_richtext(aes(label =paste0("**", classification, "**<br>(",value, ")")),size =6, fill =NA,hjust =0.5, vjust =0.5,label.color =NA, family ="Times New Roman") +scale_fill_manual(values = classification_colors, name ="Classification") +labs(title ="Confusion Matrix for Standard Logistic Regression",subtitle ="Final Model",x ="Actual Species",y ="Predicted Species") +facet_grid(Actual ~ Prediction, scales ="free", space ="free") +theme(plot.title =element_text(family ="Times New Roman",size =16,hjust =0.5,face ="bold"),plot.subtitle =element_text(family ="Times New Roman",size =12,hjust =0.5),axis.title =element_text(family ="Times New Roman",face ="bold",size =13),axis.text =element_text(family ="Times New Roman", size =10,face ="italic"),axis.text.y =element_text(angle =90, hjust =0.5,vjust =0.5),strip.text =element_blank(),legend.position ="right",legend.title =element_text(family ="Times New Roman",face ="bold",size =10),legend.text =element_text(family ="Times New Roman",size =10),legend.box.background =element_rect(color ="black", size =1))#print plotfinal_cm_plot
The final standard logistic regression model (A1) demonstrated strong performance for both species, correctly classifying 91.75% of Sabal etonia plants and 88.74% of Serenoa repens. False positives (n = 114) occurred when Sabal etonia individuals were misclassified as Serenoa repens, while false negatives (n = 79) occurred in the opposite direction.
Overall, the model performs slightly better on Sabal etonia, with fewer false negatives. These results suggest that the selected predictors (maximum height, canopy length, canopy width, and green leaf count) are effective in distinguishing the two species, and that the model generalizes well across the dataset.