# Import necessary functions to use
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from scipy import statsClassification
Will this Passholder Renew for Next Season?
Built and evaluated logistic regression and random forest models in Python to predict passholder renewal behavior using demographic, behavioral, and engagement data. Applied data cleaning, feature engineering, model interpretation, and performance evaluation to generate actionable insights for targeted marketing and retention strategy, complemented by an interactive Tableau KPI dashboard.
Part I: Logistic Regression Model
passholder = pd.read_csv("data/lobsterland_passholders_dataset_25.csv")passholder.head()| Age | Previous_Visits | Total_Spend_2024 | Feedback_Score | Gold_Zone_Visits | Email_Engagement_Score | Distance_From_Park_Miles | Home_State | Preferred_Attraction | Referral_Source | Dining_Plan | Renewed_Pass | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 59.000000 | 3.000000 | 394.780000 | 4.606232 | 4.000000 | 44.200000 | 1.900000 | VT | Thrill | Social Media | Upgraded | 0 |
| 1 | 27.846318 | 1.064843 | 172.541334 | 2.102264 | 2.000819 | 37.103840 | 21.709631 | NH | Other | Ad/Other | NaN | 1 |
| 2 | 18.000000 | 5.926144 | 151.731274 | 3.365074 | 1.019084 | 61.827722 | 21.892411 | VT | Entertainment | Social Media | Upgraded | 1 |
| 3 | 25.000000 | 12.000000 | 251.300000 | 3.458663 | 1.000000 | 41.800000 | 6.600000 | NY | Other | Friend | Upgraded | 0 |
| 4 | 66.206178 | 4.038141 | 197.686695 | 2.985864 | 3.955755 | 61.174332 | 29.981959 | NJ | Entertainment | Ad/Other | Upgraded | 1 |
passholder.info()<class 'pandas.DataFrame'>
RangeIndex: 1000 entries, 0 to 999
Data columns (total 12 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Age 1000 non-null float64
1 Previous_Visits 1000 non-null float64
2 Total_Spend_2024 1000 non-null float64
3 Feedback_Score 1000 non-null float64
4 Gold_Zone_Visits 1000 non-null float64
5 Email_Engagement_Score 1000 non-null float64
6 Distance_From_Park_Miles 1000 non-null float64
7 Home_State 1000 non-null str
8 Preferred_Attraction 1000 non-null str
9 Referral_Source 1000 non-null str
10 Dining_Plan 667 non-null str
11 Renewed_Pass 1000 non-null int64
dtypes: float64(7), int64(1), str(4)
memory usage: 93.9 KB
Numerical Variables: Age, Previous_Visits, Total_Spend_2024, Feedback_Score, Gold_Zone_Visits, Email_Engagement_Score, Distance_From_Park_Miles
Categorical Variables: Home_State, Preferred_Attraction, Referral_Source, Dining_Plan
Renewed_Pass: binary target (0/1 or bool); treat as outcome, not a predictor.
# Convert the variables to the categorical data type
passholder[['Home_State', 'Preferred_Attraction', 'Referral_Source', 'Dining_Plan']] = passholder[['Home_State', 'Preferred_Attraction', 'Referral_Source', 'Dining_Plan']].astype('category')
# Convert the Renewed_Pass to bool
passholder[['Renewed_Pass']] = passholder[['Renewed_Pass']].astype('bool')
# Double-check to see if all the variables return to the correct type
passholder.info()<class 'pandas.DataFrame'>
RangeIndex: 1000 entries, 0 to 999
Data columns (total 12 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Age 1000 non-null float64
1 Previous_Visits 1000 non-null float64
2 Total_Spend_2024 1000 non-null float64
3 Feedback_Score 1000 non-null float64
4 Gold_Zone_Visits 1000 non-null float64
5 Email_Engagement_Score 1000 non-null float64
6 Distance_From_Park_Miles 1000 non-null float64
7 Home_State 1000 non-null category
8 Preferred_Attraction 1000 non-null category
9 Referral_Source 1000 non-null category
10 Dining_Plan 667 non-null category
11 Renewed_Pass 1000 non-null bool
dtypes: bool(1), category(4), float64(7)
memory usage: 59.9 KB
passholder['Renewed_Pass'].value_counts()Renewed_Pass
True 580
False 420
Name: count, dtype: int64
By using the value_counts() function, I found that out of 1,000 passholders, 580 renewed their passes (True) and 420 did not (False). This means that about 58% of customers chose to renew, while 42% did not. Understanding this distribution helps LobsterLand’s management identify the current renewal rate and focus on strategies to increase it—for example, by attracting non-renewers with targeted promotions or loyalty programs.
Missing values
passholder.isnull().sum()Age 0
Previous_Visits 0
Total_Spend_2024 0
Feedback_Score 0
Gold_Zone_Visits 0
Email_Engagement_Score 0
Distance_From_Park_Miles 0
Home_State 0
Preferred_Attraction 0
Referral_Source 0
Dining_Plan 333
Renewed_Pass 0
dtype: int64
passholder['Dining_Plan'].head()0 Upgraded
1 NaN
2 Upgraded
3 Upgraded
4 Upgraded
Name: Dining_Plan, dtype: category
Categories (1, str): ['Upgraded']
# Add category 'None' to 'Dining_Plan' variable then fill any NaN values with 'None'
passholder['Dining_Plan'] = passholder['Dining_Plan'].cat.add_categories(['None']).fillna('None')# Double-check to see if 'Dining_Plan' variable still has any missing values
passholder['Dining_Plan'].isnull().sum()
passholder['Dining_Plan'].unique<bound method Series.unique of 0 Upgraded
1 None
2 Upgraded
3 Upgraded
4 Upgraded
...
995 Upgraded
996 Upgraded
997 Upgraded
998 Upgraded
999 Upgraded
Name: Dining_Plan, Length: 1000, dtype: category
Categories (2, str): ['Upgraded', 'None']>
According to the dataset description, the Dining_Plan variable indicates whether a passholder purchased a Lobster Land food plan, with two possible options: ‘None’ and ‘Upgraded’, where Upgraded includes both the Standard and Premium tiers. There are 333 missing values in this variable, which likely represent passholders who did not purchase any dining plan. For consistency, I replaced all missing values with ‘None’ to accurately reflect this behavior and ensure the data remains complete for analysis.
Impossible values
passholder.describe()| Age | Previous_Visits | Total_Spend_2024 | Feedback_Score | Gold_Zone_Visits | Email_Engagement_Score | Distance_From_Park_Miles | |
|---|---|---|---|---|---|---|---|
| count | 1000.000000 | 1000.000000 | 1000.000000 | 1000.000000 | 1000.000000 | 1000.000000 | 1000.000000 |
| mean | 45.843471 | 4.739685 | 211.420490 | 3.231483 | 2.045040 | 50.055373 | 30.708474 |
| std | 16.278317 | 2.291030 | 105.815989 | 0.992790 | 1.411507 | 19.941021 | 17.108942 |
| min | 18.000000 | 0.000000 | -39.950000 | 1.000000 | 0.000000 | -8.100000 | 0.100000 |
| 25% | 33.000000 | 3.000000 | 142.775364 | 2.585852 | 1.000000 | 35.975548 | 18.300000 |
| 50% | 46.278713 | 5.000000 | 199.942199 | 3.306673 | 2.000000 | 50.450000 | 29.837614 |
| 75% | 61.000000 | 6.005339 | 274.070000 | 3.826473 | 3.000000 | 62.783218 | 43.797075 |
| max | 74.000000 | 12.000000 | 559.830000 | 5.000000 | 8.000000 | 108.300000 | 87.100000 |
Are there any values in this dataset that appear to be impossible? If so, why? If not, why not?
# View rows with negative Total_Spend_2024 and Email_Engagement_Score outside of range 0-100
passholder[(passholder['Total_Spend_2024'] < 0)]| Age | Previous_Visits | Total_Spend_2024 | Feedback_Score | Gold_Zone_Visits | Email_Engagement_Score | Distance_From_Park_Miles | Home_State | Preferred_Attraction | Referral_Source | Dining_Plan | Renewed_Pass | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 228 | 34.632594 | 2.948977 | -1.000297 | 3.072944 | 0.965401 | 53.158994 | 53.764673 | ME | Other | Ad/Other | Upgraded | True |
| 280 | 34.092198 | 2.986994 | -0.508581 | 3.033482 | 0.977585 | 52.641486 | 53.059526 | ME | Other | Ad/Other | Upgraded | False |
| 465 | 54.000000 | 6.000000 | -39.950000 | 3.142010 | 4.000000 | 58.900000 | 10.300000 | NY | Other | Ad/Other | Upgraded | True |
| 477 | 34.430205 | 2.951587 | -0.751035 | 3.027981 | 0.996431 | 51.767003 | 52.981610 | ME | Other | Ad/Other | Upgraded | True |
| 485 | 33.764976 | 2.934015 | -1.852003 | 3.043365 | 1.004973 | 53.342196 | 53.162699 | ME | Other | Ad/Other | Upgraded | True |
| 493 | 26.000000 | 5.000000 | -2.230000 | 3.555003 | 2.000000 | 55.600000 | 27.800000 | VT | Thrill | Ad/Other | Upgraded | True |
| 499 | 33.327308 | 2.984666 | -1.439116 | 3.037064 | 0.967710 | 53.574152 | 53.156368 | ME | Other | Ad/Other | Upgraded | False |
| 573 | 42.000000 | 4.000000 | -23.910000 | 3.239964 | 6.000000 | 66.800000 | 32.500000 | MA | Other | Social Media | Upgraded | True |
| 750 | 33.715105 | 3.037335 | -3.514091 | 3.059007 | 1.022880 | 53.124907 | 53.273690 | ME | Other | Ad/Other | Upgraded | True |
| 761 | 52.000000 | 4.000000 | -3.180000 | 3.991413 | 4.000000 | 66.700000 | 51.200000 | NJ | Entertainment | Ad/Other | Upgraded | False |
| 884 | 34.367413 | 3.052398 | -1.531736 | 3.076422 | 0.990859 | 52.913776 | 54.164073 | ME | Other | Ad/Other | Upgraded | True |
# View rows with Email_Engagement_Score outside of range 0-100
passholder[(passholder['Email_Engagement_Score'] < 0) | (passholder['Email_Engagement_Score'] > 100)]| Age | Previous_Visits | Total_Spend_2024 | Feedback_Score | Gold_Zone_Visits | Email_Engagement_Score | Distance_From_Park_Miles | Home_State | Preferred_Attraction | Referral_Source | Dining_Plan | Renewed_Pass | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 308 | 42.0 | 2.0 | 385.92 | 2.830563 | 1.0 | 102.9 | 9.4 | MA | Other | Ad/Other | None | False |
| 379 | 21.0 | 4.0 | 345.79 | 2.868348 | 1.0 | -8.1 | 39.3 | ME | Thrill | Ad/Other | None | False |
| 418 | 37.0 | 4.0 | 339.49 | 3.264726 | 1.0 | 105.3 | 34.9 | VT | Entertainment | Ad/Other | None | True |
| 902 | 55.0 | 4.0 | 150.17 | 4.445888 | 0.0 | 108.3 | 41.9 | NY | Thrill | Ad/Other | Upgraded | False |
# Drop the negative Total_Spend_2024 value by filtering it to be greater than 0
passholder = passholder[passholder['Total_Spend_2024'] >= 0]The negative values in Total_Spend_2024 range from –39 to –0.50, which isn’t much and could possibly represent small refunds from Lobster Land to customers. However, negative spending contradicts the variable’s definition, which means to capture the total amount spent by a passholder during the season. Even if these small negatives reflect refunds, they should be tracked separately to avoid misrepresenting overall spending behavior. Since there are only 11 negative values, accounting for less than 1.5% of the dataset, I decided to drop them to maintain a clean and accurate dataset for further analysis.
# Cap Email_Engagement_Score at 100 for those exceeding the 100 range
passholder['Email_Engagement_Score'] = passholder['Email_Engagement_Score'].clip(upper=100)I capped the Email_Engagement_Score at 100 because a few users had scores slightly above that limit (102.9; 105.3; 108.3), likely due to very high engagement. All three users visited Lobster Land at least twice, showing a 100% return rate after their first visit, which is possibly influenced by follow-up emails. Clipping these minor outliers keeps the metric consistent with its defined scale while preserving meaningful behavioral signals in the data.
# Drop the negative Email_Engagement_Score value by filtering it to be greater than 0
passholder = passholder[passholder['Email_Engagement_Score'] >= 0]I decided to drop the row with a negative Email_Engagement_Score because it’s supposed to stay within the 0–100 range, and negative values don’t have any meaningful interpretation. Since only one row is affected, I felt removing it would keep the dataset clean without introducing bias and ensure consistent analysis.
# Re-check to see if the dataset contains any nan value
passholder.isnull().sum()Age 0
Previous_Visits 0
Total_Spend_2024 0
Feedback_Score 0
Gold_Zone_Visits 0
Email_Engagement_Score 0
Distance_From_Park_Miles 0
Home_State 0
Preferred_Attraction 0
Referral_Source 0
Dining_Plan 0
Renewed_Pass 0
dtype: int64
Examining correlations
Build a correlation table to examine the correlations among your numeric independent variables.
# Filter out numeric variables to examine the correlations
numeric_vars = passholder.select_dtypes(include=['float64', 'int64'])
# Create a correlation table with numeric variables from the dataset
corr_table = numeric_vars.corr()
print(corr_table.round(3).to_string()) Age Previous_Visits Total_Spend_2024 Feedback_Score Gold_Zone_Visits Email_Engagement_Score Distance_From_Park_Miles
Age 1.000 0.072 0.135 0.009 0.020 -0.037 0.100
Previous_Visits 0.072 1.000 0.054 -0.070 -0.084 -0.071 -0.094
Total_Spend_2024 0.135 0.054 1.000 0.046 -0.054 -0.001 0.065
Feedback_Score 0.009 -0.070 0.046 1.000 0.027 -0.092 -0.033
Gold_Zone_Visits 0.020 -0.084 -0.054 0.027 1.000 0.023 -0.092
Email_Engagement_Score -0.037 -0.071 -0.001 -0.092 0.023 1.000 -0.093
Distance_From_Park_Miles 0.100 -0.094 0.065 -0.033 -0.092 -0.093 1.000
# Display the correlation table in heatmap
plt.figure(figsize=(8,6))
sns.heatmap(corr_table, annot=True, cmap='coolwarm', fmt=".2f")
plt.title("Correlation Heatmap of Numeric Variables")
plt.show()
Are there any correlations here that are so high as to present a likely problem with multicollinearity?
The correlation table indicates that none of the variables are strongly related to one another, as all coefficients are well below the typical threshold for multicollinearity (r ≥ 0.8). The highest correlation, 0.135 between Age and Total_Spend_2024 indicating older renewers tend to spend more than the younger audience, is too weak to raise any concerns. Therefore, I can keep all numeric predictors for the modeling stage.
For any variables that need to be dummified, dummify them, being sure to drop one level as you do.
# Dummify all categorical variables and dropping one category per variable to avoid multicollinearity
passholder = pd.get_dummies(passholder, columns=[
'Home_State',
'Preferred_Attraction',
'Referral_Source',
'Dining_Plan'
], drop_first=True)passholder.head(5)| Age | Previous_Visits | Total_Spend_2024 | Feedback_Score | Gold_Zone_Visits | Email_Engagement_Score | Distance_From_Park_Miles | Renewed_Pass | Home_State_ME | Home_State_NH | Home_State_NJ | Home_State_NY | Home_State_VT | Preferred_Attraction_Other | Preferred_Attraction_Thrill | Referral_Source_Friend | Referral_Source_Social Media | Dining_Plan_None | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 59.000000 | 3.000000 | 394.780000 | 4.606232 | 4.000000 | 44.200000 | 1.900000 | False | False | False | False | False | True | False | True | False | True | False |
| 1 | 27.846318 | 1.064843 | 172.541334 | 2.102264 | 2.000819 | 37.103840 | 21.709631 | True | False | True | False | False | False | True | False | False | False | True |
| 2 | 18.000000 | 5.926144 | 151.731274 | 3.365074 | 1.019084 | 61.827722 | 21.892411 | True | False | False | False | False | True | False | False | False | True | False |
| 3 | 25.000000 | 12.000000 | 251.300000 | 3.458663 | 1.000000 | 41.800000 | 6.600000 | False | False | False | False | True | False | True | False | True | False | False |
| 4 | 66.206178 | 4.038141 | 197.686695 | 2.985864 | 3.955755 | 61.174332 | 29.981959 | True | False | False | True | False | False | False | False | False | False | False |
After dummifying the categorical variables, I set the following reference levels: Home_State – MA, Preferred_Attraction – Entertainment, Referral_Source – Ad/Other, and Dining_Plan – Upgraded. These serve as the baseline categories against which other levels are compared in the model. The Renewed_Pass variable is already numeric and binary, so it does not require dummification.
Create a data partition. Assign 40% of your rows to your test set, and 60% to your training set.
from sklearn.model_selection import train_test_split
train_df, test_df = train_test_split(passholder, test_size=0.4, random_state=156)
print(f"Training set size: {len(train_df)} rows")
print(f"Test set size: {len(test_df)} rows")Training set size: 592 rows
Test set size: 396 rows
For the random seed, I chose 156 because it matches the last three digits of my BUID. Using a personal but simple reference like this helps ensure consistency while keeping the seed easy to remember. It also guarantees that my results are reproducible if I need to rerun the analysis.
Compare the mean values of the variables in the dataset after grouping by Renewed_Pass.
# Change Renewed_Pass to Class 0/1
passholder['Renewed_Pass'] = passholder['Renewed_Pass'].astype(int)
passholder.groupby('Renewed_Pass').mean(numeric_only=True)| Age | Previous_Visits | Total_Spend_2024 | Feedback_Score | Gold_Zone_Visits | Email_Engagement_Score | Distance_From_Park_Miles | Home_State_ME | Home_State_NH | Home_State_NJ | Home_State_NY | Home_State_VT | Preferred_Attraction_Other | Preferred_Attraction_Thrill | Referral_Source_Friend | Referral_Source_Social Media | Dining_Plan_None | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Renewed_Pass | |||||||||||||||||
| 0 | 47.381249 | 4.672605 | 217.665995 | 3.176391 | 2.014964 | 51.457782 | 30.060697 | 0.110577 | 0.127404 | 0.165865 | 0.201923 | 0.290865 | 0.454327 | 0.348558 | 0.194712 | 0.211538 | 0.358173 |
| 1 | 44.929268 | 4.811163 | 210.848800 | 3.272654 | 2.067988 | 48.989534 | 30.889094 | 0.159091 | 0.173077 | 0.125874 | 0.178322 | 0.208042 | 0.477273 | 0.325175 | 0.145105 | 0.225524 | 0.319930 |
From the results you see here, choose any THREE independent variables from the dataset, and speculate about their likely impact on the result – do you think this variable will be strongly impactful?
I chose these three variables: Email_Engagement_Score, Total_Spend_2024, and Dining_Plan_None because they each capture different aspects of customer behavior that could influence renewal decisions. Email_Engagement_Score reflects digital interaction and communication responsiveness, Total_Spend_2024 shows spending patterns for each visitors, and Dining_Plan_None indicates spending habits and overall commitment to the park experience. Together, these variables provide a balanced mix of behavioral, experiential, and transactional factors that could help explain differences between renewers and non-renewers.
- Email_Engagement_Score: Non-renewers have a slightly higher average score (51.45) than renewers (48.98). This suggests that email engagement may not directly drive renewal, or that more engaged users are more critical.
- Total_Spend_2024: Non-renewers spent slightly more on average in 2024 (\$217.67) than renewers (\$210.85), which is unexpected if we assume higher spend reflects stronger loyalty. This could suggest that one-time high spenders don’t necessarily return, or that renewers are more consistent but spend less per season.
- Dining_Plan_None: Non-renewers are more likely to have no dining plan (35.81%) compared to renewers (31.99%), suggesting that opting out of the food plan may correlate with lower overall commitment or value perception.
Iteration #1
Build a logistic regression model using statsmodels, with the outcome variable ‘Renewed_Pass’.
passholder.head()| Age | Previous_Visits | Total_Spend_2024 | Feedback_Score | Gold_Zone_Visits | Email_Engagement_Score | Distance_From_Park_Miles | Renewed_Pass | Home_State_ME | Home_State_NH | Home_State_NJ | Home_State_NY | Home_State_VT | Preferred_Attraction_Other | Preferred_Attraction_Thrill | Referral_Source_Friend | Referral_Source_Social Media | Dining_Plan_None | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 59.000000 | 3.000000 | 394.780000 | 4.606232 | 4.000000 | 44.200000 | 1.900000 | 0 | False | False | False | False | True | False | True | False | True | False |
| 1 | 27.846318 | 1.064843 | 172.541334 | 2.102264 | 2.000819 | 37.103840 | 21.709631 | 1 | False | True | False | False | False | True | False | False | False | True |
| 2 | 18.000000 | 5.926144 | 151.731274 | 3.365074 | 1.019084 | 61.827722 | 21.892411 | 1 | False | False | False | False | True | False | False | False | True | False |
| 3 | 25.000000 | 12.000000 | 251.300000 | 3.458663 | 1.000000 | 41.800000 | 6.600000 | 0 | False | False | False | True | False | True | False | True | False | False |
| 4 | 66.206178 | 4.038141 | 197.686695 | 2.985864 | 3.955755 | 61.174332 | 29.981959 | 1 | False | False | True | False | False | False | False | False | False | False |
print(train_df.dtypes)Age float64
Previous_Visits float64
Total_Spend_2024 float64
Feedback_Score float64
Gold_Zone_Visits float64
Email_Engagement_Score float64
Distance_From_Park_Miles float64
Renewed_Pass bool
Home_State_ME bool
Home_State_NH bool
Home_State_NJ bool
Home_State_NY bool
Home_State_VT bool
Preferred_Attraction_Other bool
Preferred_Attraction_Thrill bool
Referral_Source_Friend bool
Referral_Source_Social Media bool
Dining_Plan_None bool
dtype: object
# Convert all bool variables to numeric form
train_df = train_df.astype({col: int for col in train_df.select_dtypes('bool').columns})import statsmodels.api as sm
X_train = train_df.drop(columns=['Renewed_Pass'])
y_train = train_df['Renewed_Pass']
X_train = sm.add_constant(X_train)
logit_model = sm.Logit(y_train, X_train)
passholder_model = logit_model.fit()Optimization terminated successfully.
Current function value: 0.647964
Iterations 5
Show the summary of your model with log_reg.summary().
passholder_model.summary()| Dep. Variable: | Renewed_Pass | No. Observations: | 592 |
|---|---|---|---|
| Model: | Logit | Df Residuals: | 574 |
| Method: | MLE | Df Model: | 17 |
| Date: | Wed, 04 Feb 2026 | Pseudo R-squ.: | 0.04379 |
| Time: | 20:03:21 | Log-Likelihood: | -383.59 |
| converged: | True | LL-Null: | -401.16 |
| Covariance Type: | nonrobust | LLR p-value: | 0.005978 |
| coef | std err | z | P>|z| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| const | 0.7841 | 0.694 | 1.130 | 0.258 | -0.576 | 2.144 |
| Age | -0.0079 | 0.005 | -1.462 | 0.144 | -0.019 | 0.003 |
| Previous_Visits | 0.0647 | 0.041 | 1.568 | 0.117 | -0.016 | 0.146 |
| Total_Spend_2024 | -0.0004 | 0.001 | -0.411 | 0.681 | -0.002 | 0.001 |
| Feedback_Score | 0.1498 | 0.093 | 1.618 | 0.106 | -0.032 | 0.331 |
| Gold_Zone_Visits | 0.0494 | 0.064 | 0.769 | 0.442 | -0.076 | 0.175 |
| Email_Engagement_Score | 4.514e-05 | 0.005 | 0.010 | 0.992 | -0.009 | 0.009 |
| Distance_From_Park_Miles | 0.0031 | 0.005 | 0.559 | 0.576 | -0.008 | 0.014 |
| Home_State_ME | -0.2888 | 0.357 | -0.810 | 0.418 | -0.988 | 0.410 |
| Home_State_NH | -0.1766 | 0.364 | -0.486 | 0.627 | -0.889 | 0.536 |
| Home_State_NJ | -1.1599 | 0.349 | -3.322 | 0.001 | -1.844 | -0.476 |
| Home_State_NY | -0.6981 | 0.329 | -2.122 | 0.034 | -1.343 | -0.053 |
| Home_State_VT | -1.0633 | 0.322 | -3.299 | 0.001 | -1.695 | -0.432 |
| Preferred_Attraction_Other | -0.2398 | 0.239 | -1.004 | 0.315 | -0.708 | 0.228 |
| Preferred_Attraction_Thrill | -0.5000 | 0.251 | -1.988 | 0.047 | -0.993 | -0.007 |
| Referral_Source_Friend | -0.3234 | 0.246 | -1.313 | 0.189 | -0.806 | 0.159 |
| Referral_Source_Social Media | -0.0085 | 0.221 | -0.039 | 0.969 | -0.442 | 0.425 |
| Dining_Plan_None | 0.0198 | 0.186 | 0.106 | 0.916 | -0.346 | 0.385 |
Which of your numeric variables here are showing high p-values?
A p-value greater than 0.05 means the variable is not statistically significant, suggesting that any observed effect could easily be due to random chance rather than a real relationship with renewal behavior. All the numeric variables display relatively high p-values, suggesting that none are statistically significant predictors of renewal at the 0.05 level. Age (p = 0.144), Previous_Visits (p = 0.117), Feedback_Score (p = 0.106), and Distance_From_Park_Miles (p = 0.576) show weak associations with renewal status, while Total_Spend_2024 (p = 0.681) and Email_Engagement_Score (p = 0.992) are particularly uninformative. This indicates that these factors do not have a meaningful impact on predicting renewal behavior.
For your categorical variables, which ones are showing high p-values for ALL of the levels in the model summary?
For the categorical variables, both Referral_Source and Dining_Plan have high p-values across all levels: Referral_Source_Friend (p = 0.189), Referral_Source_Social Media (p = 0.969), and Dining_Plan_None (p = 0.916) suggesting they are not statistically significant overall.
On the other hand, several Home_State variables are significant, including NJ (p = 0.001), NY (p = 0.034), and VT (p = 0.001), indicating that passholder’s location may impact one’s renewal behavior. Similarly, Preferred_Attraction_Thrill is significant (p = 0.047), implying that customers who prefer thrill rides are less likely to renew compared to those who prefer entertainment attractions. Overall, these results suggest that geographic and experiential factors have stronger predictive power than engagement or spending-related measures.
Iteration #2
Build yet another model, use training set only.
For the new model, I’m keeping only the variables that showed a meaningful relationship with renewal in the previous analysis. This includes Home_State (specifically NJ, NY, and VT) and Preferred_Attraction_Thrill, since these had p-values below 0.05 and appear to influence whether a passholder renews.
All other variables are being dropped because they were not statistically significant. This includes all the numeric variables, such as Age, Previous_Visits, Total_Spend_2024, Feedback_Score, Gold_Zone_Visits and Email_Engagement_Score, as well as categorical variables: Referral_Source and Dining_Plan. Though Home_State and Preferred_Attraction have some insignificant variables, these sections still contain meaningful variables, so I will keep all of them for consistent result.
# Drop all insignificant variables for the 2nd model including all numeric variables and some categorical variables
drop_vars = [
'Age', 'Previous_Visits', 'Total_Spend_2024', 'Feedback_Score',
'Gold_Zone_Visits', 'Email_Engagement_Score', 'Distance_From_Park_Miles',
'Referral_Source_Friend', 'Referral_Source_Social Media', 'Dining_Plan_None'
]
X_train = train_df.drop(columns=['Renewed_Pass'] + drop_vars)
y_train = train_df['Renewed_Pass']
X_train = sm.add_constant(X_train)
logit_model = sm.Logit(y_train, X_train)
passholder_model2 = logit_model.fit()
passholder_model2.summary()Optimization terminated successfully.
Current function value: 0.655800
Iterations 5
| Dep. Variable: | Renewed_Pass | No. Observations: | 592 |
|---|---|---|---|
| Model: | Logit | Df Residuals: | 584 |
| Method: | MLE | Df Model: | 7 |
| Date: | Wed, 04 Feb 2026 | Pseudo R-squ.: | 0.03222 |
| Time: | 20:03:21 | Log-Likelihood: | -388.23 |
| converged: | True | LL-Null: | -401.16 |
| Covariance Type: | nonrobust | LLR p-value: | 0.0005348 |
| coef | std err | z | P>|z| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| const | 1.2775 | 0.318 | 4.020 | 0.000 | 0.655 | 1.900 |
| Home_State_ME | -0.3568 | 0.347 | -1.028 | 0.304 | -1.037 | 0.323 |
| Home_State_NH | -0.1293 | 0.346 | -0.373 | 0.709 | -0.808 | 0.550 |
| Home_State_NJ | -1.1510 | 0.344 | -3.343 | 0.001 | -1.826 | -0.476 |
| Home_State_NY | -0.7526 | 0.322 | -2.340 | 0.019 | -1.383 | -0.122 |
| Home_State_VT | -1.0290 | 0.315 | -3.267 | 0.001 | -1.646 | -0.412 |
| Preferred_Attraction_Other | -0.2879 | 0.232 | -1.238 | 0.216 | -0.744 | 0.168 |
| Preferred_Attraction_Thrill | -0.4526 | 0.245 | -1.846 | 0.065 | -0.933 | 0.028 |
Using scikit-learn, build another version of your model, using your remaining variables.
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
X = passholder[['Home_State_ME','Home_State_NH','Home_State_NJ', 'Home_State_NY', 'Home_State_VT', 'Preferred_Attraction_Other', 'Preferred_Attraction_Thrill']]
y = passholder['Renewed_Pass']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.4, random_state=156
)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
logmodel = LogisticRegression(max_iter=1000)
logmodel.fit(X_train_scaled, y_train)
print(f"Intercept: {logmodel.intercept_[0]:.3f}")
print("\nCoefficient:")
for feature, coef in zip(X_train.columns, logmodel.coef_[0]):
print(f"{feature}: {coef:.3f}")Intercept: 0.372
Coefficient:
Home_State_ME: -0.115
Home_State_NH: -0.035
Home_State_NJ: -0.384
Home_State_NY: -0.286
Home_State_VT: -0.417
Preferred_Attraction_Other: -0.139
Preferred_Attraction_Thrill: -0.211
Assess the performance of your model against the test set. Build a confusion matrix, and answer the following questions about your model.
# Import metric functions from scikit-learn
from sklearn.metrics import confusion_matrix, accuracy_score, recall_score, precision_score, balanced_accuracy_score
y_pred = logmodel.predict(X_test_scaled)
passholder_cm = confusion_matrix(y_test, y_pred)
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
print("The model's confusion matrix:\n", passholder_cm)The model's confusion matrix:
[[ 59 113]
[ 69 155]]
plt.figure(figsize=(5, 4))
sns.heatmap(passholder_cm, annot=True, fmt='d', cmap='coolwarm',
xticklabels=['Actual 1', 'Actual 0'],
yticklabels=['Predict 1', 'Predict 0'])
plt.xlabel("Actual Label")
plt.ylabel("Predicted Label")
plt.title("Confusion Matrix")
plt.show()
The model correctly predicted 59 as non‑renewal (TN) and incorrectly predicted as 113 renewal (FP). While it correctly predicted 155 as renewal (TP) and incorrectly predicted 69 as non‑renewal (FN).
What is your model’s accuracy rate?
accuracy = accuracy_score(y_test, y_pred)
print(f"Accuracy Rate: {accuracy:.3f}")Accuracy Rate: 0.540
What is your model’s sensitivity rate?
sensitivity = recall_score(y_test, y_pred)
print(f"Sensitivity Rate: {sensitivity:.3f}")Sensitivity Rate: 0.692
What is your model’s specificity rate?
specificity = tn / (tn + fp)
print(f"Specificity Rate: {specificity:.3f}")Specificity Rate: 0.343
What is your model’s precision?
precision = precision_score(y_test, y_pred)
print(f"Precision: {precision:.3f}")Precision: 0.578
What is your model’s balanced accuracy?
balanced_accuracy = balanced_accuracy_score(y_test, y_pred)
print(f"Balanced Accuracy: {balanced_accuracy:.3f}")Balanced Accuracy: 0.517
The model shows good sensitivity (0.692), meaning it captures most renewals, but low specificity (0.343), missing many non-renewals. With overall accuracy at 51.7%, which is slightly above baseline, suggesting that it leans toward predicting renewals and struggles to distinguish non-renewals effectively.
What is the naive accuracy rate (baseline rate) for this dataset?
baseline_rate = max(y_test.mean(), 1 - y_test.mean())
print(f"Naive Accuracy Rate: {baseline_rate:.3f}")Naive Accuracy Rate: 0.566
How does this compare with your model’s accuracy rate? If a classification model’s accuracy is not much better than the naive rate, can the model still be useful? If so, how?
My model achieved an accuracy of 51.7%, which is slightly lower than the naive baseline rate of 56.6%, representing the proportion of the majority class. Although it didn’t outperform a simple majority prediction, accuracy alone doesn’t capture the model’s full value. The model can still be useful if it improves metrics like sensitivity or precision, especially when correctly identifying renewals would help with targeted marketing and better allocation.
Compare your model’s accuracy against the training set vs. accuracy against the test set.
# Accuracy score for trained dataset
train_pred = logmodel.predict(X_train_scaled)
train_acc = accuracy_score(y_train, train_pred)
# Accuracy score for tested dataset
test_pred = logmodel.predict(X_test_scaled)
test_acc = accuracy_score(y_test, test_pred)
print(f"Accuracy score for training set: {train_acc:.3f}")
print(f"Accuracy score for test set: {test_acc:.3f}")Accuracy score for training set: 0.620
Accuracy score for test set: 0.540
What is the purpose of comparing those two values?
Comparing the training and test accuracy helps determine how well the model performs on new data. When there is a large difference between the two values, it often means the model is overfitting, whereas the model is doing well on the training data but struggling to generalize. A smaller difference usually means the model is more consistent and better at handling unseen data.
In this case, what does the comparison of those values suggest about the model that you have built?
In this case, the training accuracy (62.0%) is only slightly higher than the test accuracy (54.0%), so overfitting is not the main concern. However, both accuracy rates are relatively low and close to the naive baseline, which could indicate that the model is underfitting or that the chosen variables don’t provide enough predictive evidence. Additional data or more informative features might be needed to increase the model’s efficiency.
Make up a passholder. Assign this customer a value for each predictor variable in this model, and store the results in a new dataframe.
# Sample passholder A who's from NY and preferred thrill attraction
passholder_A = pd.DataFrame([{
'Home_State_ME': 0,
'Home_State_NH': 0,
'Home_State_NJ': 0,
'Home_State_NY': 1,
'Home_State_VT': 0,
'Preferred_Attraction_Other': 0,
'Preferred_Attraction_Thrill': 1
}])
# Scale using previously fitted scaler
passholder_A_scaled = scaler.transform(passholder_A)
# Predict renewal probability
passholder_A_prob = logmodel.predict_proba(passholder_A_scaled)[0][1]
passholder_A_class = logmodel.predict(passholder_A_scaled)[0]
print(f"Predicted Class for Passholder A: {passholder_A_class}")
print(f"Predicted Renewal Probability for Passholder A: {passholder_A_prob:.3f}")Predicted Class for Passholder A: 1
Predicted Renewal Probability for Passholder A: 0.519
What did your model predict – will this passholder renew?
To test the model, I created Passholder A, who is from New York (NY) and prefers Thrill Attractions. Based on the model’s prediction, Passholder A falls into class 1, meaning they are expected to renew their pass at Lobster Land.
According to your model, what is the probability that the passholder will renew?
According to the model, the predicted probability of renewal for Passholder A is 51.9%, indicating that while there is some likelihood of non-renewal, it is above the 50% threshold used for classifying renewers.
When using a logistic regression model to make predictions, why is it important to only use values within the range of the dataset used to build the model?
Make a new dataframe, but this time, for the numeric predictor variables, select some numbers that are outside the range of the dataset. Use your model to make a prediction for this new dataframe.
# Create unrealistic but structurally valid input for passholder Z
passholder_Z = pd.DataFrame([{
'Home_State_ME': 0,
'Home_State_NH': 0,
'Home_State_NJ': 0,
'Home_State_NY': 1,
'Home_State_VT': 0,
'Preferred_Attraction_Other': 0,
'Preferred_Attraction_Thrill': 999
}])
passholder_Z_scaled = scaler.transform(passholder_Z)
passholder_Z_prob = logmodel.predict_proba(passholder_Z_scaled)[0][1]
passholder_Z_class = logmodel.predict(passholder_Z_scaled)[0]
print(f"Predicted Class for Passholder Z: {passholder_Z_class}")
print(f"Predicted Probability for Passholder Z: {passholder_Z_prob:.3f}")Predicted Class for Passholder Z: 0
Predicted Probability for Passholder Z: 0.000
What do you notice about the result?
To test the model, I created Passholder Z from New York (NY) who prefers Thrill Attractions, but assigned an unrealistic value of 999 for Thrill preference, far beyond the training range. Logistic regression handles extreme values by pushing the log-odds sharply, which makes the predicted probability collapse to 0 or 1. As a result, the model predicted a renewal probability of 0.000 and classified this passholder as class 0, demonstrating why input values need to remain within realistic ranges for reliable predictions.
Part II: Random Forest Model
Read the dataset back into Python
passholder_rf = pd.read_csv("data/lobsterland_passholders_dataset_25.csv")# Convert the variables to the categorical data type
passholder_rf[['Home_State', 'Preferred_Attraction', 'Referral_Source', 'Dining_Plan']] = passholder_rf[['Home_State', 'Preferred_Attraction', 'Referral_Source', 'Dining_Plan']].astype('category')
# Add category 'None' to 'Dining_Plan' variable then fill any NaN values with 'None'
passholder_rf['Dining_Plan'] = passholder_rf['Dining_Plan'].cat.add_categories(['None']).fillna('None')
# Drop the negative Total_Spend_2024 value by filtering it to be greater than 0
passholder_rf = passholder_rf[passholder_rf['Total_Spend_2024'] >= 0]
# Cap Email_Engagement_Score at 100 for those exceeding the 100 range
passholder_rf['Email_Engagement_Score'] = passholder_rf['Email_Engagement_Score'].clip(upper=100)
# Drop the negative Email_Engagement_Score value by filtering it to be greater than 0
passholder_rf = passholder_rf[passholder_rf['Email_Engagement_Score'] >= 0]Dummify the categorical inputs again, but this time, don’t drop any levels.
# Dummify all categorical variables and no drop
passholder_rf = pd.get_dummies(passholder_rf, columns=[
'Home_State',
'Preferred_Attraction',
'Referral_Source',
'Dining_Plan'
], drop_first=False)
passholder_rf.head()| Age | Previous_Visits | Total_Spend_2024 | Feedback_Score | Gold_Zone_Visits | Email_Engagement_Score | Distance_From_Park_Miles | Renewed_Pass | Home_State_MA | Home_State_ME | ... | Home_State_NY | Home_State_VT | Preferred_Attraction_Entertainment | Preferred_Attraction_Other | Preferred_Attraction_Thrill | Referral_Source_Ad/Other | Referral_Source_Friend | Referral_Source_Social Media | Dining_Plan_Upgraded | Dining_Plan_None | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 59.000000 | 3.000000 | 394.780000 | 4.606232 | 4.000000 | 44.200000 | 1.900000 | 0 | False | False | ... | False | True | False | False | True | False | False | True | True | False |
| 1 | 27.846318 | 1.064843 | 172.541334 | 2.102264 | 2.000819 | 37.103840 | 21.709631 | 1 | False | False | ... | False | False | False | True | False | True | False | False | False | True |
| 2 | 18.000000 | 5.926144 | 151.731274 | 3.365074 | 1.019084 | 61.827722 | 21.892411 | 1 | False | False | ... | False | True | True | False | False | False | False | True | True | False |
| 3 | 25.000000 | 12.000000 | 251.300000 | 3.458663 | 1.000000 | 41.800000 | 6.600000 | 0 | False | False | ... | True | False | False | True | False | False | True | False | True | False |
| 4 | 66.206178 | 4.038141 | 197.686695 | 2.985864 | 3.955755 | 61.174332 | 29.981959 | 1 | False | False | ... | False | False | True | False | False | True | False | False | True | False |
5 rows × 22 columns
Re-partition the data, using the same seed value that you used in the previous part of this assignment.
train_df, test_df = train_test_split(passholder_rf, test_size=0.4, random_state=156)
print(f"Training set size: {len(train_df)} rows")
print(f"Test set size: {len(test_df)} rows")Training set size: 592 rows
Test set size: 396 rows
Build a random forest model in Python with your training set (the only difference here is that the categories should not have any levels dropped). Use GridSearchCV to help you determine the best hyperparameter settings for your model.
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier
X_rf = passholder_rf.drop('Renewed_Pass', axis=1)
y_rf = passholder_rf['Renewed_Pass']
X_train_rf, X_test_rf, y_train_rf, y_test_rf = train_test_split(
X_rf, y_rf, test_size=0.4, random_state=156
)# Set up a parameter grid for hyperparameter tuning.
param_grid = {
'n_estimators': [50, 100, 150],
'max_depth': [None, 5, 10],
'min_samples_split': [2, 5],
'min_samples_leaf': [1, 2]}
# Instantiate the Random Forest classifier with a fixed random state.
rf = RandomForestClassifier(random_state=156)
# Use GridSearchCV for hyperparameter tuning with 5-fold cross-validation.
gridsearch_rf = GridSearchCV(estimator=rf, param_grid=param_grid, cv=5, n_jobs=-1, verbose=1)
gridsearch_rf.fit(X_train_rf, y_train_rf)
print("Best parameters found:", gridsearch_rf.best_params_)
bestparam_rf = gridsearch_rf.best_estimator_Fitting 5 folds for each of 36 candidates, totalling 180 fits
Best parameters found: {'max_depth': None, 'min_samples_leaf': 1, 'min_samples_split': 5, 'n_estimators': 50}
How did your random forest model rank the variables in order of importance, from highest to lowest?
# Extract feature importances from the best random forest model
importances_rf = bestparam_rf.feature_importances_
features_rf = X_train_rf.columns
importance_df = pd.DataFrame({'Feature': features_rf, 'Importance': importances_rf})
importance_df = importance_df.sort_values(by='Importance', ascending=False)
print("Random Forest feature importances ranked from highest to lowest:")
importance_dfRandom Forest feature importances ranked from highest to lowest:
| Feature | Importance | |
|---|---|---|
| 6 | Distance_From_Park_Miles | 0.134086 |
| 3 | Feedback_Score | 0.126785 |
| 0 | Age | 0.123248 |
| 5 | Email_Engagement_Score | 0.122290 |
| 2 | Total_Spend_2024 | 0.116996 |
| 1 | Previous_Visits | 0.099605 |
| 4 | Gold_Zone_Visits | 0.096552 |
| 10 | Home_State_NJ | 0.016589 |
| 14 | Preferred_Attraction_Other | 0.015116 |
| 12 | Home_State_VT | 0.015014 |
| 7 | Home_State_MA | 0.014658 |
| 16 | Referral_Source_Ad/Other | 0.014174 |
| 13 | Preferred_Attraction_Entertainment | 0.013437 |
| 15 | Preferred_Attraction_Thrill | 0.013159 |
| 9 | Home_State_NH | 0.013115 |
| 18 | Referral_Source_Social Media | 0.012260 |
| 8 | Home_State_ME | 0.011718 |
| 19 | Dining_Plan_Upgraded | 0.010869 |
| 17 | Referral_Source_Friend | 0.010784 |
| 20 | Dining_Plan_None | 0.009866 |
| 11 | Home_State_NY | 0.009680 |
For a random forest model, how can you interpret feature importance?
The random forest model ranked variables by how much they reduced classification error across all trees. The top predictors were Distance_From_Park_Miles (0.134), Feedback_Score (0.127), Age (0.123), Email_Engagement_Score (0.122), and Total_Spend_2024 (0.117), while categorical factors like Home_State and Preferred_Attraction had much lower importance (around 0.01–0.02).
Unlike logistic regression, which shows the direction of relationships, random forest feature importance only measures how strongly each variable contributes to predictions. For Lobster Land, this means focusing on key behavioral drivers such as engagement and spending will likely have the greatest impact on improving renewal rates, while factors such as Home_State or Preferred_Attraction appear to play a much smaller role.
Assess the performance of your model against the test set using confusion matrix to do this.
# Predict outcomes for the Random Forest test set using the best Random Forest model
y_pred_rf = bestparam_rf.predict(X_test_rf)
passholder_cm_rf = confusion_matrix(y_test_rf, y_pred_rf)
tn, fp, fn, tp = confusion_matrix(y_test_rf, y_pred_rf).ravel()
print("The model's confusion matrix:\n", passholder_cm_rf)The model's confusion matrix:
[[ 55 117]
[ 62 162]]
plt.figure(figsize=(5, 4))
sns.heatmap(passholder_cm_rf, annot=True, fmt='d', cmap='BuGn',
xticklabels=['Actual 1', 'Actual 0'],
yticklabels=['Predict 1', 'Predict 0'])
plt.xlabel("Actual Label")
plt.ylabel("Predicted Label")
plt.title("Random Forest Confusion Matrix")
plt.show()
What is your model’ s accuracy rate?
accuracy_rf = accuracy_score(y_test_rf, y_pred_rf)
print(f"Random Forest Accuracy Rate: {accuracy_rf:.3f}")Random Forest Accuracy Rate: 0.548
What is your model’s sensitivity rate?
sensitivity_rf = recall_score(y_test_rf, y_pred_rf)
print(f"Random Forest Sensitivity Rate: {sensitivity_rf:.3f}")Random Forest Sensitivity Rate: 0.723
What is your model’s specificity rate?
specificity_rf = tn / (tn + fp)
print(f"Random Forest Specificity Rate: {specificity_rf:.3f}")Random Forest Specificity Rate: 0.320
What is your model’s precision?
precision_rf = precision_score(y_test_rf, y_pred_rf)
print(f"Random Forest Precision: {precision_rf:.3f}")Random Forest Precision: 0.581
What is your model’s balanced accuracy?
balanced_accuracy_rf = balanced_accuracy_score(y_test_rf, y_pred_rf)
print(f"Random Forest Balanced Accuracy: {balanced_accuracy_rf:.3f}")Random Forest Balanced Accuracy: 0.521
Compare your model’s accuracy against the training set vs. your model’s accuracy against the test set.
# Accuracy score for RF trained dataset
train_pred_rf = bestparam_rf.predict(X_train_rf)
train_acc_rf = accuracy_score(y_train_rf, train_pred)
# Accuracy score for RF tested dataset
test_pred_rf = bestparam_rf.predict(X_test_rf)
test_acc_rf = accuracy_score(y_test_rf, test_pred)
print(f"Accuracy score for Random Forest training set: {train_acc_rf:.3f}")
print(f"Accuracy score for Random Forest test set: {test_acc_rf:.3f}")Accuracy score for Random Forest training set: 0.610
Accuracy score for Random Forest test set: 0.543
How different were these results?
The random forest model performed slightly better on the training data (61.0%) than on the test data (54.3%), showing a modest gap of about 6.7 percentage points. The small gap suggests the Random Forest isn’t overfitting but may have limited generalization, likely because the features don’t fully explain renewal behavior or key factors are missing from the data.
Use the predict() function with your model to classify the person who you invented in the previous section.
# Start with a template row of zeros for then update with selected features
passholder_A = pd.DataFrame([dict.fromkeys(X_train_rf.columns, 0)])
passholder_A['Home_State_NY'] = 1
passholder_A['Preferred_Attraction_Thrill'] = 1
pred_class = bestparam_rf.predict(passholder_A)[0]
pred_prob = bestparam_rf.predict_proba(passholder_A)[0][1]
print(f"Predicted Class for Passholder A (Random Forest): {pred_class}")
print(f"Predicted Renewal Probability (Random Forest): {pred_prob:.3f}")Predicted Class for Passholder A (Random Forest): 1
Predicted Renewal Probability (Random Forest): 0.504
Does the model think that the passholder will renew?
The Random Forest model predicts that Passholder A will renew (class 1) with a renewal probability of 50.4%, though this suggests a moderate likelihood of renewing their pass.
Lobster Land can use these models as practical tools to identify which passholders are more or less likely to renew, focusing on the variables that stood out in both analyses. The logistic regression pointed to factors like Home_State and Preferred_Attraction types, suggesting opportunities for local/geographical or interest-based marketing. The random forest, on the other hand, added further insights by focusing on behavioral and engagement factors: Distance_From_Park_Miles, Feedback_Score, Total_Spending_2024, and Previous_Visits as key drivers of renewal.
By combining these insights, Lobster Land can target passholders who live farther away, show lower satisfaction, or engage less often, while rewarding loyal visitors. For instance, the park could offer transportation discounts, personalized follow-ups, or loyalty perks to strengthen renewal intent and use its marketing resources more effectively.
Part III: Tableau Dashboard
Here’s the public link for my dashboard: https://public.tableau.com/app/profile/nhat.tran5503/viz/LobsterLandKPIDashBoard2025/Dashboard1?publish=yes

The Lobster Land KPI Dashboard reveals patterns and trends that help management understand park performance and make informed decisions. It shows which days see the most visitors, how weather impacts ticket sales, and which attractions and seasons generate the most revenue. By tracking these metrics, management can also see whether the park is meeting its KPIs, such as visitor targets, revenue goals, and event performance, and use the insights to adjust staffing, marketing, and resources as needed.
Average Visitors by Day of the Week (Line Chart): This chart shows how attendance varies across the week, with weekends (Friday–Saturday) drawing the highest visitor counts. It helps identify peak days for staffing and promotions.
Day Tickets Sold by Weather Type (Box Plot): The box plots compare ticket sales under different weather conditions, revealing how sunny days show the highest ticket sales, while rainy or stormy conditions lead to fewer visitors, highlighting the strong influence of weather on attendance.
Total Revenue by Attraction Tiers (Pie Chart): The pie chart highlights how Tier 1 generate the largest share of revenue compared to Tier 2 and Tier 3. This emphasizes the importance of maintaining and marketing top-tier experiences.
Revenue Breakdown by Season (Bar Chart): Seasonal bars show how revenue streams (food, merchandise, events) shift across seasons. Summer produces the strongest revenue across categories, especially in food and merchandise, reflecting peak tourism and vacation travel.This helps Lobster Land plan seasonal offerings and optimize resource allocation.
International Visitors and Merch Revenue During Special Events (Combination of Bar + Line Chart): This chart compares per‑capita spending and merchandise revenue across major events, showing which events drive the most international engagement and sales. It helps prioritize which events to expand or replicate.