Market Segmentation & Conjoint Analysis

Analyzed Lobster Land’s operations using k-means clustering to segment park days based on attendance, pricing, weather, and spending behavior, uncovering distinct day types to inform staffing, pricing, and marketing decisions. Conducted a conjoint analysis with a linear regression model to quantify how entertainment features, crowding, and experience design impact guest satisfaction, translating insights into actionable recommendations for revenue and experience optimization.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans

Part I: Segmentation

# Import day_25.csv files into python for use
day_25 = pd.read_csv("data/days25.csv")
# Then use info() and head() function to show how the data is stored
day_25.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 99 entries, 0 to 98
Data columns (total 19 columns):
 #   Column                  Non-Null Count  Dtype  
---  ------                  --------------  -----  
 0   Day_ID                  99 non-null     int64  
 1   Day_of_Week             99 non-null     object 
 2   season_week_rel         99 non-null     int64  
 3   Is_Holiday              99 non-null     int64  
 4   Total_Visitors          99 non-null     int64  
 5   Passholder_Percentage   99 non-null     float64
 6   Day_Tickets_Sold        99 non-null     int64  
 7   Avg_Ticket_Price        99 non-null     float64
 8   Gate_Revenue            99 non-null     float64
 9   Revenue_Food            99 non-null     float64
 10  Revenue_Merch           99 non-null     float64
 11  Revenue_Arcade          99 non-null     float64
 12  Total_Revenue           99 non-null     float64
 13  Total_Labor_Hours       99 non-null     float64
 14  International_Visitors  99 non-null     float64
 15  High_Temperature        99 non-null     float64
 16  Weather_Type            99 non-null     object 
 17  Is_Special_Event        99 non-null     int64  
 18  Per_Capita_Spend        99 non-null     float64
dtypes: float64(11), int64(6), object(2)
memory usage: 14.8+ KB
day_25.head()
Day_ID Day_of_Week season_week_rel Is_Holiday Total_Visitors Passholder_Percentage Day_Tickets_Sold Avg_Ticket_Price Gate_Revenue Revenue_Food Revenue_Merch Revenue_Arcade Total_Revenue Total_Labor_Hours International_Visitors High_Temperature Weather_Type Is_Special_Event Per_Capita_Spend
0 1 Monday 1 1 1769 48.65 908 77.42 70297.36 40521.91 15881.290 3195.46 129896.02 238.1 114.0 74.6 Thunderstorms 0 73.43
1 2 Tuesday 1 0 1717 60.58 677 69.80 47254.60 35298.92 15882.665 2977.63 101812.36 253.4 255.0 74.0 Partly Cloudy 0 59.30
2 3 Wednesday 1 0 1600 30.84 1107 70.09 77589.63 36495.95 14954.040 2683.58 131723.20 213.3 160.0 76.8 Partly Cloudy 0 82.33
3 4 Thursday 1 0 1037 19.02 840 72.30 60732.00 22486.65 9517.060 1831.98 94567.69 192.7 83.0 75.4 Showers 0 91.19
4 5 Friday 1 0 1671 66.18 565 68.11 38482.15 40705.15 15727.620 3115.94 98030.86 235.9 158.0 80.7 Partly Cloudy 0 58.67
# Convert binary flags (0,1) including: Is_Holiday and Is_Special_Event to categorical
day_25[['Is_Holiday','Is_Special_Event']] = day_25[['Is_Holiday','Is_Special_Event']].astype('category')

# Convert season_week_rel, Day_of_Week and Weather_Type from object to categorical variables
day_25[['Day_of_Week','season_week_rel','Weather_Type']] = day_25[['Day_of_Week','season_week_rel','Weather_Type']].astype('category')
# Drop the Day_ID variable
day_25 = day_25.drop(columns=["Day_ID"])

In this case, I decided to drop Day_ID because it’s just a sequential label and doesn’t seem to offer meaningful insight for analysis or prediction. Since it might unintentionally influence models by introducing artificial patterns, it felt more appropriate to leave it out of the feature set.

# Double check to see if all the variables are stored as the appropriate data type
day_25.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 99 entries, 0 to 98
Data columns (total 18 columns):
 #   Column                  Non-Null Count  Dtype   
---  ------                  --------------  -----   
 0   Day_of_Week             99 non-null     category
 1   season_week_rel         99 non-null     category
 2   Is_Holiday              99 non-null     category
 3   Total_Visitors          99 non-null     int64   
 4   Passholder_Percentage   99 non-null     float64 
 5   Day_Tickets_Sold        99 non-null     int64   
 6   Avg_Ticket_Price        99 non-null     float64 
 7   Gate_Revenue            99 non-null     float64 
 8   Revenue_Food            99 non-null     float64 
 9   Revenue_Merch           99 non-null     float64 
 10  Revenue_Arcade          99 non-null     float64 
 11  Total_Revenue           99 non-null     float64 
 12  Total_Labor_Hours       99 non-null     float64 
 13  International_Visitors  99 non-null     float64 
 14  High_Temperature        99 non-null     float64 
 15  Weather_Type            99 non-null     category
 16  Is_Special_Event        99 non-null     category
 17  Per_Capita_Spend        99 non-null     float64 
dtypes: category(5), float64(11), int64(2)
memory usage: 12.1 KB
  • Numeric Variables: Total_Visitors, Passholder_Percentage, Day_Tickets_Sold, Avg_Ticket_Price, Gate_Revenue, Revenue_Food, Revenue_Merch, Revenue_Arcade, Total_Revenue, Total_Labor_Hours, International_Visitors, High_Temperature, Per_Capita_Spend
  • Categorical Variables: Day_ID, Day_of_Week, season_week_rel, Is_Holiday, Weather_Type, Is_Special_Event

In my opinion, it’s not a great idea to use categorical inputs in a k-means model because the algorithm calculates distances between data points, and turning categories into numbers can be misleading. Take season_week_rel for example, it uses values from 1 to 15 to represent different weeks, but those numbers don’t really capture how similar or different the weeks are in terms of things like visitor patterns or revenue. The model might end up grouping week 1 and week 2 together just because their numbers are close, even if they behave very differently. Since k-means tries to minimize distance within clusters, it could create groups that look related mathematically but don’t actually make much sense in the real-world context of the data.

Call the describe() function on your dataset.

day_25.describe()
Total_Visitors Passholder_Percentage Day_Tickets_Sold Avg_Ticket_Price Gate_Revenue Revenue_Food Revenue_Merch Revenue_Arcade Total_Revenue Total_Labor_Hours International_Visitors High_Temperature Per_Capita_Spend
count 99.000000 99.000000 99.000000 99.000000 99.000000 99.000000 99.000000 99.000000 99.000000 99.000000 99.000000 99.000000 99.000000
mean 1870.707071 41.973232 1098.292929 70.439697 79484.778182 54715.882626 18691.392273 4450.617879 147911.785253 256.535354 178.464646 77.126263 76.930808
std 641.078735 15.581994 466.750518 10.680450 37053.465251 97737.730733 8077.026667 2992.738130 62854.729274 47.232843 85.522800 13.975537 12.582334
min -50.000000 11.720000 322.000000 -25.000000 22350.020000 22486.650000 9517.060000 1831.980000 63247.570000 192.700000 -10.000000 63.800000 51.630000
25% 1482.000000 28.895000 771.000000 68.250000 53853.740000 33291.365000 13492.635000 2693.595000 103186.105000 222.400000 117.500000 70.850000 68.575000
50% 1704.000000 40.900000 995.000000 70.480000 70272.360000 38054.660000 15882.665000 3026.520000 129433.130000 243.500000 158.000000 74.800000 77.500000
75% 2080.000000 54.590000 1411.000000 73.735000 103147.720000 55612.790000 22567.790000 5419.410000 184173.685000 272.000000 232.500000 81.350000 84.385000
max 3938.000000 73.650000 3029.000000 82.280000 242229.130000 999999.990000 51601.440000 15755.610000 423019.370000 435.400000 560.000000 200.000000 110.860000

When I run the describe() function on this dataset, it provides a concise statistical summary of each numeric column, including the count, mean, standard deviation, and range values. This snapshot helps me quickly understand the distribution and behavior of each variable, which is essential for assessing data quality and preparing for modeling.

In this case, the summary reveals several problematic entries that need attention. For example, Total_Visitors, International_Visitors, and Avg_Ticket_Price contain negative values, which are not realistic in the context of theme park operations. Another noticeable issue in the summary table is that Revenue_Food shows a maximum value of 999,999.99, which exceeds the Total_Revenue maximum of 423,019.37, even though Total_Revenue should represent the combined daily revenue across all categories (gate, food, merchandise, and arcade). Identifying these issues early allows me to flag potential data entry errors or outliers and decide whether to clean, transform, or exclude certain records. This step is crucial for ensuring that the model is built on reliable and interpretable data.

Missing values / Impossible values

day_25.isnull().sum()
Day_of_Week               0
season_week_rel           0
Is_Holiday                0
Total_Visitors            0
Passholder_Percentage     0
Day_Tickets_Sold          0
Avg_Ticket_Price          0
Gate_Revenue              0
Revenue_Food              0
Revenue_Merch             0
Revenue_Arcade            0
Total_Revenue             0
Total_Labor_Hours         0
International_Visitors    0
High_Temperature          0
Weather_Type              0
Is_Special_Event          0
Per_Capita_Spend          0
dtype: int64

After running the code and checking the results for missing values, the output indicates that all columns contain zero missing values, suggesting that the dataset is complete and no data cleaning through imputation or row removal is necessary.

day_25.select_dtypes(include="number").lt(0).sum()
Total_Visitors            1
Passholder_Percentage     0
Day_Tickets_Sold          0
Avg_Ticket_Price          1
Gate_Revenue              0
Revenue_Food              0
Revenue_Merch             0
Revenue_Arcade            0
Total_Revenue             0
Total_Labor_Hours         0
International_Visitors    1
High_Temperature          0
Per_Capita_Spend          0
dtype: int64
# Look for rows with negative values in Total_Visitors, Avg_Ticket_Price and International_Visitors 
day_25[(day_25["Total_Visitors"] < 0) | 
       (day_25["Avg_Ticket_Price"] < 0) | 
       (day_25["International_Visitors"] < 0)]
Day_of_Week season_week_rel Is_Holiday Total_Visitors Passholder_Percentage Day_Tickets_Sold Avg_Ticket_Price Gate_Revenue Revenue_Food Revenue_Merch Revenue_Arcade Total_Revenue Total_Labor_Hours International_Visitors High_Temperature Weather_Type Is_Special_Event Per_Capita_Spend
10 Thursday 2 0 -50 72.26 444 69.49 30853.56 40019.25 15179.56 2722.04 88774.41 228.6 75.0 82.4 Partly Cloudy 0 55.52
20 Sunday 3 0 2691 27.24 1958 -25.00 146771.68 67432.83 26147.56 7571.99 247924.06 305.3 178.0 79.5 Cloudy 0 92.13
50 Tuesday 8 0 1204 72.60 330 68.39 22568.70 27999.37 11246.96 2202.88 64017.91 205.4 -10.0 78.0 Rain 0 53.17
# Find rows with suspiciously high food revenue
day_25[day_25["Revenue_Food"] > day_25["Total_Revenue"]]
Day_of_Week season_week_rel Is_Holiday Total_Visitors Passholder_Percentage Day_Tickets_Sold Avg_Ticket_Price Gate_Revenue Revenue_Food Revenue_Merch Revenue_Arcade Total_Revenue Total_Labor_Hours International_Visitors High_Temperature Weather_Type Is_Special_Event Per_Capita_Spend
30 Wednesday 5 0 1772 18.77 1439 69.82 100470.98 999999.99 16262.36 2974.24 165467.64 238.3 137.0 86.4 Cloudy 0 93.38
# Find rows with suspiciosly high temperature 
day_25[day_25["High_Temperature"] > 120]
Day_of_Week season_week_rel Is_Holiday Total_Visitors Passholder_Percentage Day_Tickets_Sold Avg_Ticket_Price Gate_Revenue Revenue_Food Revenue_Merch Revenue_Arcade Total_Revenue Total_Labor_Hours International_Visitors High_Temperature Weather_Type Is_Special_Event Per_Capita_Spend
40 Saturday 6 0 1933 25.14 1447 74.99 108510.53 54861.67 19528.12 5038.63 187938.95 251.4 194.0 200.0 Rain 0 97.23

To identify impossible values, I focused on numeric variables and used the command day_25.select_dtypes(include=“number”).lt(0).sum(). This function isolates numeric columns and checks for any values less than zero, providing a quick count of negative entries per column. This approach is useful because variables like visitor counts, ticket sales, and revenue figures cannot logically be negative in a theme park setting. At the same time, Passholder_Percentage falls within a reasonable 0–100% range, so those values appear consistent.

The results showed three impossible values in Total_Visitors, International_Visitors, and Avg_Ticket_Price. These values are considered impossible because visitor counts cannot be negative - they must be zero or greater. Similarly, ticket prices are typically non-negative, unless there’s a rare case involving refunds or discounts. In this context, negative values likely indicate data entry errors or corrupted records.

The summary statistics also showed that Revenue_Food has a maximum value of 999,999.99, whereas Total_Revenue only has a maximum of 423,019.37. Because Total_Revenue should include all revenue categories, this should be corrected or reviewed before any further analysis.

Another entry that appears suspicious is High_Temperature, which shows a maximum value of 200°F. While temperature ranges can vary by region, typical readings in the United States generally fall between 0°F and a little over 100°F. Even Boston’s recent heatwave only reached a record high of 102°F, so a value of 200°F is unrealistic. To verify, I filtered the High_Temperature variable for values greater than 120°F and found only one record, suggesting this is likely a data entry or recording error.

If so, handle them in any way that you see fit.

# Drop rows where Food_Revenue exceeds Total_Revenue
day_25 = day_25[day_25["Revenue_Food"] <= day_25["Total_Revenue"]]

In this case, the simplest and most reasonable solution is to remove the entire row, since even if I replaced Revenue_Food with a typical value (like the median), Total_Revenue would still end up smaller than the sum of its parts, which doesn’t make sense for this dataset.

# Drop rows where  High_Temperature is abnormally high
day_25 = day_25[day_25["High_Temperature"] <= 120]

I decided to remove the row with the unusually high temperature value instead of imputing it with the median, since it appeared to be an outlier that could distort the results. By removing it, I ensure the dataset remains accurate and free from unrealistic values.

# Filter so that all variables is non-negative
day25 = day_25[
    (day_25["Total_Visitors"] >= 0) &
    (day_25["Avg_Ticket_Price"] >= 0) &
    (day_25["International_Visitors"] >= 0)
]

day25.select_dtypes(include="number").lt(0).sum()
Total_Visitors            0
Passholder_Percentage     0
Day_Tickets_Sold          0
Avg_Ticket_Price          0
Gate_Revenue              0
Revenue_Food              0
Revenue_Merch             0
Revenue_Arcade            0
Total_Revenue             0
Total_Labor_Hours         0
International_Visitors    0
High_Temperature          0
Per_Capita_Spend          0
dtype: int64
# Now, day25 dataframe has 94 entries for each variable after dropping the 5 impossible rows.
day25.count()
Day_of_Week               94
season_week_rel           94
Is_Holiday                94
Total_Visitors            94
Passholder_Percentage     94
Day_Tickets_Sold          94
Avg_Ticket_Price          94
Gate_Revenue              94
Revenue_Food              94
Revenue_Merch             94
Revenue_Arcade            94
Total_Revenue             94
Total_Labor_Hours         94
International_Visitors    94
High_Temperature          94
Weather_Type              94
Is_Special_Event          94
Per_Capita_Spend          94
dtype: int64

I chose to drop the rows with impossible values rather than replace them with the median or mode because the errors are rare and clearly invalid. According to data_25.info() results above, I know that there are 99 entries for each variables. With only 5 affected entries out of 99, removing them avoids introducing assumptions that could distort relationships between variables. Replacing with typical values like the median might mask underlying issues or flatten meaningful variation, especially when the context behind the error is unknown. Dropping ensures the dataset remains clean and reasonable.

Variable selection. Select any 5 variables from the potential set of inputs in order to build your k-means clustering model.

var_selected = day25[["Total_Visitors","Avg_Ticket_Price",
                       "High_Temperature","Passholder_Percentage","Per_Capita_Spend"]]

To build a meaningful k-means clustering model, I selected the following five variables from the dataset: Total_Visitors, Avg_Ticket_Price, High_Temperature, Passholder_Percentage, and Per_Capita_Spend. These five variables were chosen to capture key aspects of daily park dynamics. Total_Visitors reflects overall attendance, giving a sense of how busy the park is. Avg_Ticket_Price ties into pricing strategy and its potential influence on visitor volume. High_Temperature displays a weather factor that can affect both attendance and spending. Passholder_Percentage helps distinguish between loyal visitors and one-time guests, which may impact behavior and spending habits. Per_Capita_Spend offers a normalized view of how much each visitor contributes financially, combining attendance and revenue into a single meaningful metric.

Data Scaling

Do your variables need to be standardized? Why or why not?

Yes, I do think my variables should be standardized before applying k-means clustering. This is because they are measured on very different scales. For instance, Avg_Ticket_Price is no more than about $83, while Total_Visitors can reach nearly 4,000, which is a much larger numerical range. Similarly, Passholder_Percentage is expressed as a proportion (0–100%), and High_Temperature and Per_Capita_Spend also exist on their own distinct scales. Since k-means uses Euclidean distance to group observations, variables with larger numeric ranges would otherwise dominate the clustering process. Standardizing the data ensures that each variable contributes equally to distance calculations, leading to more balanced and meaningful clusters.

If your data requires standardization, use Python to convert your values into z-scores, and store the normalized data in a new dataframe. If not, proceed to the next step without changing the variables.

# Import scaler function to standardize dataframe
scaler = StandardScaler()
var_scaled = scaler.fit_transform(var_selected)
# Create a new dataframe with standardized values
var_scaled_df = pd.DataFrame(var_scaled, columns = var_selected.columns)

print(var_scaled_df.head())
   Total_Visitors  Avg_Ticket_Price  High_Temperature  Passholder_Percentage  \
0       -0.196940          1.312127         -0.163867               0.452158   
1       -0.281650         -0.361277         -0.259344               1.252239   
2       -0.472247         -0.297591          0.186213              -0.742262   
3       -1.389393          0.187740         -0.036565              -1.534966   
4       -0.356585         -0.732412          0.806810               1.627801   

   Per_Capita_Spend  
0         -0.285287  
1         -1.460976  
2          0.455238  
3          1.192436  
4         -1.513395  

Elbow Chart

# Define X with 5 selected columns from the standardized data
X = var_scaled_df[["Total_Visitors",
                   "Avg_Ticket_Price",
                   "High_Temperature",
                   "Passholder_Percentage",
                   "Per_Capita_Spend"
                  ]]

# Build the elbow chart
sse = []
cluster_range = range(1, 15)
for k in cluster_range:
    kmeans = KMeans(n_clusters=k, random_state=616)
    kmeans.fit(X)
    sse.append(kmeans.inertia_)

# Plot the Elbow chart
plt.figure(figsize=(8, 5))
plt.plot(cluster_range, sse, marker='o')
plt.xlabel("Number of Clusters")
plt.ylabel("SSE (Sum of Squared Errors)")
plt.title("Elbow Chart for K-means Clustering")
plt.xticks(cluster_range)
plt.show()

How many clusters will you use for your k-means model?

The elbow chart above shows the SSE decreasing sharply from around 480 to approximately 175 as the number of clusters increases. The most significant drop occurs between 1 and 4 clusters, after which the curve begins to flatten, suggesting diminishing returns from adding more clusters. Based on this pattern, I selected k = 4 for the k-means model, as it captures most of the variance in the data while maintaining a manageable number of segments.

A smaller number of clusters (e.g., 2 or 3) would likely combine distinct park-day types, such as slower weekdays and peak weekends - into the same group, masking meaningful operational differences. By using four clusters, the park can better understand how key factors like visitor volume, pricing, weather, and spending behavior interact across different day types.

Build a k-means model with your desired number of clusters.

# Build and fit the K-Means model on the standardized DataFrame 'X'
kmeans_model = KMeans(n_clusters = 4, random_state = 616)
kmeans_model.fit(X)

# Add cluster labels to the original standardized df
var_scaled_df['Cluster'] = kmeans_model.labels_

print("K-Means Cluster Centers (for 4 clusters):")
print(var_scaled_df)
K-Means Cluster Centers (for 4 clusters):
    Total_Visitors  Avg_Ticket_Price  High_Temperature  Passholder_Percentage  \
0        -0.196940          1.312127         -0.163867               0.452158   
1        -0.281650         -0.361277         -0.259344               1.252239   
2        -0.472247         -0.297591          0.186213              -0.742262   
3        -1.389393          0.187740         -0.036565              -1.534966   
4        -0.356585         -0.732412          0.806810               1.627801   
..             ...               ...               ...                    ...   
89       -0.742667         -0.567707         -0.084304              -0.679222   
90       -0.242553         -0.211944         -1.325497              -0.760370   
91        1.531466          1.031030         -0.609424              -0.212452   
92       -0.009601         -0.225120         -0.800377               0.871312   
93        0.837497          0.752130         -0.338907              -0.750310   

    Per_Capita_Spend  Cluster  
0          -0.285287        0  
1          -1.460976        0  
2           0.455238        3  
3           1.192436        3  
4          -1.513395        1  
..               ...      ...  
89          0.366209        3  
90          0.213943        3  
91          0.434437        2  
92         -0.678848        0  
93          0.615824        3  

[94 rows x 6 columns]

I built the k-means model using a data frame containing only the 5 selected variables. By setting random_state = 616, I ensured that the clustering results remain consistent across runs. I selected 3 clusters to achieve a more detailed and meaningful segmentation of the park.

Generate Centroid Values

# Calculate centroid values for each cluster
centroids = var_scaled_df.groupby('Cluster').mean()

# Display the centroids
print(centroids)
         Total_Visitors  Avg_Ticket_Price  High_Temperature  \
Cluster                                                       
0             -0.592234         -0.358172         -0.670880   
1             -0.295578         -0.381371          1.168029   
2              1.977070          1.522274          0.319635   
3             -0.081737         -0.055474         -0.251885   

         Passholder_Percentage  Per_Capita_Spend  
Cluster                                           
0                     0.643200         -0.830625  
1                     0.596481         -0.552168  
2                     0.303739          0.530955  
3                    -1.079094          0.882159  
cluster_counts = var_scaled_df['Cluster'].value_counts().sort_index()
print("\nCluster Counts:")
print(cluster_counts)

Cluster Counts:
Cluster
0    29
1    20
2    13
3    32
Name: count, dtype: int64

Build four simple visualizations to help management better understand your clusters

Visualization 1: Histogram of Total Visitors by Cluster

plt.figure(figsize=(8, 5))
sns.histplot(data=var_scaled_df, x="Total_Visitors", hue="Cluster", multiple="stack", palette="Set2")
plt.title("Distribution of Total Visitors by Cluster")
plt.xlabel("Total Visitors (Standardized)")
plt.ylabel("Frequency")
plt.tight_layout()
plt.show()

This histogram shows the distribution of Total Visitors(standardized) across the four clusters groupped by the model, with the x-axis representing Total Visitors to Lobster Land and the y-axis showing their frequency. Cluster 0 (green) is concentrated around lower standardized visitor counts, suggesting it represents quieter, low-traffic days. Cluster 1 (orange) sits in the middle, reflecting moderate visitor days, not too busy nor too quiet. Cluster 2 (blue) dominates the higher end of the scale, capturing peak attendance periods, while Cluster 3 (pink) spans wider range, suggesting it includes a more diverse mix of visitors who vary from occasional guests to highly engaged ones.

Although this is a simple chart, it’s crucial for Lobster Land, as it clearly shows how each cluster reacts differently in terms of attendance and helps the team plan targeted promotions and engagement strategies tailored to each group’s behavior.

Visualization 2: Barplot Average Ticket Price per Cluster

plt.figure(figsize=(8, 5))
sns.barplot(data=var_scaled_df, x="Cluster", y="Avg_Ticket_Price", hue="Cluster", palette="Set2", dodge=False, legend=False, edgecolor="black")
plt.title("Average Ticket Price per Cluster")
plt.xlabel("Cluster")
plt.ylabel("Average Ticket Price (Standardized)")
plt.tight_layout()
plt.show()

This bar chart illustrates the mean standardized average ticket price across the four clusters identified by the model. Cluster 2 (blue) shows the highest average ticket price, representing premium or high-demand days. Clusters 0 (green) and 1 (orange) display below-average ticket prices, with Cluster 1 slightly lower than Cluster 0, indicating more budget-friendly or passholder-heavy days. Cluster 3 (pink) remains close to the standardized mean, reflecting more typical pricing conditions across Lobster Land’s visitor segments. This chart helps Lobster Land better understand how ticket prices vary across different visitor groups, allowing the team to align pricing strategies and promotions with each segment’s spending behavior.

Visualization 3: Scatterplot of Passholder Percentage vs Per Capita Spend colored by Cluster

plt.figure(figsize=(8, 5))
sns.scatterplot(data=var_scaled_df, x="Passholder_Percentage", y="Per_Capita_Spend", hue="Cluster", palette="Set2")
plt.title("Passholder Percentage vs Per Capita Spend by Cluster")
plt.xlabel("Passholder Percentage")
plt.ylabel("Per Capita Spend")
plt.tight_layout()
plt.show()

This scatter plot shows how Passholder Percentage relates to Per Capita Spend across the four clusters identified by the model. I noticed a clear pattern where clusters with a higher share of passholders tend to have lower spending per person, while those with fewer passholders show higher spending levels. Cluster 0 (green) has a wide range of passholder percentages but generally lower spending, whereas Cluster 3 (pink) stands out with a smaller share of passholders and higher spending, likely representing one-time or premium visitors. Whereas Cluster 2 (blue) shows a distinct profile, with moderate passholder percentages and positive per capita spending, reflecting a balanced mix of loyal and casual visitors at Lobster Land. From this chart, we can see how different visitor types behave at Lobster Land, helping identify ways to balance loyalty programs with opportunities to attract higher-value guests.

Visualization 4: Boxplot High_Temperature per Cluster

plt.figure(figsize=(8, 5))
sns.boxplot(data=var_scaled_df, x="Cluster", y="High_Temperature", hue="Cluster", palette="Set2", dodge=False)
plt.title("High Temperature by Cluster")
plt.xlabel("Cluster")
plt.ylabel("High Temperature (Standardized)")
plt.tight_layout()
plt.show()

This boxplot shows how standardized high temperatures vary across the four clusters identified by the model. Cluster 1 (orange) has the highest median temperature, representing hotter days, while Clusters 0 (green) and 3 (pink) are generally cooler. Cluster 2 (blue) shows the widest temperature range, including both hot and cool days, which may reflect mixed or transitional seasonal conditions at Lobster Land.

Cluster Descriptions

Cluster 0: “Chill & Sparse”

These are the cool, calm days with lower visitor counts, below-average ticket prices, and a higher share of passholders. Spending per person is lighter, creating an easygoing park atmosphere that appeals to loyal guests and locals who enjoy a quieter visit. - Targeting: LobsterLand can focus on community connections and rewarding frequent visitors on these days. Promotions highlighting relaxed park experiences, quiet attractions, or weekday benefits can draw local families and passholders seeking a slower, more comfortable visit.

Cluster 1: “Scorch & Stroll”

This cluster displays the warmest temperatures, moderate attendance, and lower ticket prices. Spending per capita is modest, and many visitors hold passes, creating a steady but easygoing flow of activity across the park. - Targeting: The marketing team can emphasize refreshment and comfort through shaded seating, cool treat bundles or special water rides. Bright and refreshing summer campaigns that highlight leisurely fun and family-friendly attractions can reach guests looking for simple, enjoyable park days.

Cluster 2: “Gold Rush Days”

Cluster 2 represents the park’s busiest, most profitable days with high visitor counts, the highest standardized ticket prices, and strong per capita spending. With a balanced mix of loyal and casual visitors, these are the moments when the park operates at full capacity and captures its greatest market potential. - Targeting: These days can spotlight premium experiences such as fast passes, dining packages, and exclusive access areas. Promotions on travel platforms and event partnerships can reach visitors planning full-day trips or group outings, aligning offerings with elevated park energy and spending activity.

Cluster 3: “Balanced Buzz”

This cluster shows moderate temperatures, mixed visitor counts, and steady spending levels. Ticket prices remain close to the average, and the lower passholder percentage suggests a healthy mix of new and returning guests. - Targeting: With a blend of loyal customers and newcomers, LobsterLand can use this opportunity to run limited-time offers, themed events, or new experiences can help identify emerging interests.

How can Lobster Land use this model?

Lobster Land can use this day-type clustering model as a practical tool to help the team make better decisions about staffing, pricing, and creating great experiences for guests. By figuring out which cluster an upcoming day fits into, like a busy “Gold Rush Day” or a quieter “Chill & Sparse” day, the team can get ahead of operations instead of just reacting. On high-traffic days, the park can bring in extra staff, stay open later, and offer special upgrades that guests will actually want. On slower days, there’s room to focus on rewarding regulars, running leaner operations, or taking care of behind-the-scenes maintenance that’s hard to do when the park is packed.

The marketing team can also use these insights to shape promotions that actually fit the day. Take “Scorch & Stroll” days, for instance. These hot, leisurely days might be perfect for promoting cold drink bundles or access to shaded seating areas. “Balannced Buzz” days, with their unexpected energy, could be a testing ground for new attractions or digital campaigns to see what resonates. As Lobster Land builds out this approach, weaving in weather forecasts and calendar events could help predict cluster assignments days or even weeks ahead. That shifts the park from reactive scrambling to a proactive, rhythm-based planning style that aligns operations with real guest patterns and business priorities.

If the team could predict which cluster a day might fall into, it would make planning feel a lot more purposeful and efficient. For instance, if an upcoming weekend looks like a “Gold Rush Day,” the park could bring in extra staff, stock up on food and drinks, and roll out seasonal specials like fall-inspired treats or Halloween-themed beverages to attract both new visitors and returning fans. On the other hand, if a slower Tuesday seems to fall into the “Chill & Sparse” cluster, Lobster Land could use the opportunity to run local promotions, offer discounted afternoon passes, or host a cozy themed dining night for family and friends. This kind of planning helps the park stay balanced—keeping operations efficient, guests happy, and business decisions aligned with real demand.

Part II: Conjoint Analysis with a Linear Model

# Import night_show.csv files into python for use
night_show = pd.read_csv("/Users/nhattran/Downloads/MSBA/METAD654 - Marketing Analytics/assignment 2/night_show.csv",keep_default_na=False, na_values=[])
night_show.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 73728 entries, 0 to 73727
Data columns (total 12 columns):
 #   Column            Non-Null Count  Dtype  
---  ------            --------------  -----  
 0   bundleID          73728 non-null  int64  
 1   headliner         73728 non-null  object 
 2   music_atmosphere  73728 non-null  object 
 3   show_length       73728 non-null  int64  
 4   peak_dB_Level     73728 non-null  int64  
 5   crowding_index    73728 non-null  int64  
 6   viewing_zone      73728 non-null  object 
 7   dining_bundle     73728 non-null  object 
 8   re_entry          73728 non-null  object 
 9   live_host         73728 non-null  object 
 10  charity_tie_in    73728 non-null  object 
 11  ratings           73728 non-null  float64
dtypes: float64(1), int64(4), object(7)
memory usage: 6.8+ MB
night_show.head()
bundleID headliner music_atmosphere show_length peak_dB_Level crowding_index viewing_zone dining_bundle re_entry live_host charity_tie_in ratings
0 1 Fireworks Pop Soundtrack 30 65 40 front plaza None None Yes Yes 4.249
1 2 Fireworks Pop Soundtrack 30 65 40 front plaza None None Yes No/Doesn't Matter 4.689
2 3 Fireworks Pop Soundtrack 30 65 40 front plaza None None No/Doesn't Matter Yes 7.999
3 4 Fireworks Pop Soundtrack 30 65 40 front plaza None None No/Doesn't Matter No/Doesn't Matter 4.309
4 5 Fireworks Pop Soundtrack 30 65 40 front plaza None One Yes Yes 6.239

Based on the 12 variables provided in the dataset, here’s the list of numeric and categorical variables: - Numerical Variables: show_length (in minutes); peak_dB_Level (decibels level); crowding_index (crowd density); ratings (average ratings from 0 t0 10).

  • Categorical Variables: bundleID, headliner, music_atmosphere, viewing_zone, dining_bundle, re_entry, live_host, and charity_tie_in, as they represent distinct categories or labels rather than quantities with mathematical meaning.

  • I also dropped bundleID from the dataset because it functions solely as a sequential identifier without providing any analytical or predictive value. Including it could potentially create misleading patterns in the model, so excluding it prevents future problems.

night_show = night_show.drop(columns=["bundleID"])
# Define the columns to convert
categorical_cols = ["headliner", "music_atmosphere", "viewing_zone", "dining_bundle",
                    "re_entry", "live_host", "charity_tie_in"]
# Convert to category dtype
night_show[categorical_cols] = night_show[categorical_cols].astype('category')
night_show.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 73728 entries, 0 to 73727
Data columns (total 11 columns):
 #   Column            Non-Null Count  Dtype   
---  ------            --------------  -----   
 0   headliner         73728 non-null  category
 1   music_atmosphere  73728 non-null  category
 2   show_length       73728 non-null  int64   
 3   peak_dB_Level     73728 non-null  int64   
 4   crowding_index    73728 non-null  int64   
 5   viewing_zone      73728 non-null  category
 6   dining_bundle     73728 non-null  category
 7   re_entry          73728 non-null  category
 8   live_host         73728 non-null  category
 9   charity_tie_in    73728 non-null  category
 10  ratings           73728 non-null  float64 
dtypes: category(7), float64(1), int64(3)
memory usage: 2.7 MB

Use the pandas get_dummies() function in order to prepare these variables for use in a linear model. Inside this function, include this argument: drop_first = True. Doing this will save us from the multicollinearity problem that would make our model unreliable.

# Before dummifying the variables, check to see if the dataset has any null input
night_show.isnull().values.any()
np.False_
night_show.columns
Index(['headliner', 'music_atmosphere', 'show_length', 'peak_dB_Level',
       'crowding_index', 'viewing_zone', 'dining_bundle', 're_entry',
       'live_host', 'charity_tie_in', 'ratings'],
      dtype='object')
night_show_dm = pd.get_dummies(night_show, drop_first=True, columns=['headliner', 'music_atmosphere', 'show_length', 'peak_dB_Level',
       'crowding_index', 'viewing_zone', 'dining_bundle', 're_entry','live_host', 'charity_tie_in'])
night_show_dm.head()
ratings headliner_Drone Light Show headliner_Fireworks headliner_Live Musical music_atmosphere_Pop Soundtrack show_length_70 show_length_110 show_length_150 peak_dB_Level_85 peak_dB_Level_105 ... viewing_zone_boardwalk viewing_zone_castle_courtyard viewing_zone_front plaza dining_bundle_dessert_party dining_bundle_meal_package dining_bundle_snack_voucher re_entry_One re_entry_Unlimited live_host_Yes charity_tie_in_Yes
0 4.249 False True False True False False False False False ... False False True False False False False False True True
1 4.689 False True False True False False False False False ... False False True False False False False False True False
2 7.999 False True False True False False False False False ... False False True False False False False False False True
3 4.309 False True False True False False False False False ... False False True False False False False False False False
4 6.239 False True False True False False False False False ... False False True False False False True False True True

5 rows × 23 columns

night_show_dm.columns
Index(['ratings', 'headliner_Drone Light Show', 'headliner_Fireworks',
       'headliner_Live Musical', 'music_atmosphere_Pop Soundtrack',
       'show_length_70', 'show_length_110', 'show_length_150',
       'peak_dB_Level_85', 'peak_dB_Level_105', 'crowding_index_60',
       'crowding_index_80', 'crowding_index_100', 'viewing_zone_boardwalk',
       'viewing_zone_castle_courtyard', 'viewing_zone_front plaza',
       'dining_bundle_dessert_party', 'dining_bundle_meal_package',
       'dining_bundle_snack_voucher', 're_entry_One', 're_entry_Unlimited',
       'live_host_Yes', 'charity_tie_in_Yes'],
      dtype='object')

Why should the numeric input variables based on this survey data be dummified?

Numeric input variables from survey data should be converted into dummy variables because even though they appear as numbers, they represent discrete categories rather than continuous measurements. Take show_length for example, it might include values such as 30, 70, 110, and 150 minutes. These values are not part of a linear scale but rather specific options provided by the survey design. Treating them as continuous would incorrectly assume that the difference between 30 and 70 minutes has the same proportional relationship as the difference between 110 and 150 minutes, which may not reflect reality.

By converting these numeric inputs into dummy variables, the model can assign independent effects to each category without imposing misleading linear assumptions. This approach improves both the accuracy and interpretability of the model while ensuring that the analysis respects the categorical structure inherent in survey-based numeric data.

It might seem tempting here to just skip the model-building step – instead of building a model, we could just rank the bundles by average rating, in descending order, and then just tell Lobster Land to implement all the features in the highest-rated bundle.

Ranking bundles by their average rating might seem ideal, but it hides the true drivers of guest satisfaction. Without building a model, it’s difficult to tell which specific features within each bundle actually influence ratings or how they interact with one another. For example, a highly rated bundle might include both a live musical and unlimited re-entry, but we would not know that the musical boosts satisfaction while the re-entry option reduces it. A conjoint analysis model solves this by estimating the independent effect of each feature across all bundles, allowing Lobster Land to predict how new combinations would perform and design offerings that maximize impact while keeping costs manageable. In short, the model provides a deeper, data-driven understanding of guest preferences that simple ranking cannot achieve.

Build a linear model with your data, using the average rating as the outcome variable, and with all of your other variables (other than the bundleID) as inputs.

I build a linear regression model using all dummified input features to predict the average rating. This allows to estimate how each individual feature contributes to guest satisfaction.

# Create Linear Regression model
X = night_show_dm[['headliner_Drone Light Show', 'headliner_Fireworks',
       'headliner_Live Musical', 'music_atmosphere_Pop Soundtrack',
       'show_length_70', 'show_length_110', 'show_length_150',
       'peak_dB_Level_85', 'peak_dB_Level_105', 'crowding_index_60',
       'crowding_index_80', 'crowding_index_100', 'viewing_zone_boardwalk',
       'viewing_zone_castle_courtyard', 'viewing_zone_front plaza',
       'dining_bundle_dessert_party', 'dining_bundle_meal_package',
       'dining_bundle_snack_voucher', 're_entry_One', 're_entry_Unlimited',
       'live_host_Yes', 'charity_tie_in_Yes']]
y = night_show_dm["ratings"]
# Import LinearRegression function from scikit‑learn
from sklearn.linear_model import LinearRegression

regressor = LinearRegression()
regressor.fit(X, y)
LinearRegression()
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
regressor.intercept_
print(f"Intercept: {regressor.intercept_:.3f}")
Intercept: 4.953
import statsmodels.api as sm

# Define outcome and predictors
y = night_show_dm['ratings']
X_model = night_show_dm.drop(columns=['ratings'])

# Add intercept term
X_model = sm.add_constant(X_model)
# Convert boolean columns to numeric
bool_cols = X_model.select_dtypes(include=['bool']).columns
X_model[bool_cols] = X_model[bool_cols].astype(int)
X_model = X_model.astype(float)

# Fit the model
linear_model = sm.OLS(y, X_model).fit()
print(linear_model.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:                ratings   R-squared:                       0.345
Model:                            OLS   Adj. R-squared:                  0.345
Method:                 Least Squares   F-statistic:                     1766.
Date:                Mon, 12 Jan 2026   Prob (F-statistic):               0.00
Time:                        23:18:18   Log-Likelihood:            -1.4678e+05
No. Observations:               73728   AIC:                         2.936e+05
Df Residuals:                   73705   BIC:                         2.938e+05
Df Model:                          22                                         
Covariance Type:            nonrobust                                         
===================================================================================================
                                      coef    std err          t      P>|t|      [0.025      0.975]
---------------------------------------------------------------------------------------------------
const                               4.9533      0.031    158.283      0.000       4.892       5.015
headliner_Drone Light Show          0.0178      0.018      0.964      0.335      -0.018       0.054
headliner_Fireworks                 0.0009      0.018      0.046      0.963      -0.035       0.037
headliner_Live Musical              1.0299      0.018     55.801      0.000       0.994       1.066
music_atmosphere_Pop Soundtrack    -0.7130      0.013    -54.634      0.000      -0.739      -0.687
show_length_70                      0.7179      0.018     38.898      0.000       0.682       0.754
show_length_110                     0.9709      0.018     52.608      0.000       0.935       1.007
show_length_150                     0.7038      0.018     38.136      0.000       0.668       0.740
peak_dB_Level_85                    0.0165      0.016      1.034      0.301      -0.015       0.048
peak_dB_Level_105                  -0.8872      0.016    -55.507      0.000      -0.919      -0.856
crowding_index_60                   0.2230      0.018     12.085      0.000       0.187       0.259
crowding_index_80                   0.2323      0.018     12.588      0.000       0.196       0.268
crowding_index_100                 -0.6046      0.018    -32.760      0.000      -0.641      -0.568
viewing_zone_boardwalk              0.3889      0.018     21.070      0.000       0.353       0.425
viewing_zone_castle_courtyard       0.3560      0.018     19.291      0.000       0.320       0.392
viewing_zone_front plaza            0.3389      0.018     18.362      0.000       0.303       0.375
dining_bundle_dessert_party        -0.0028      0.018     -0.154      0.877      -0.039       0.033
dining_bundle_meal_package          0.0033      0.018      0.177      0.860      -0.033       0.039
dining_bundle_snack_voucher        -0.0922      0.018     -4.993      0.000      -0.128      -0.056
re_entry_One                        0.2968      0.016     18.569      0.000       0.265       0.328
re_entry_Unlimited                 -1.5920      0.016    -99.604      0.000      -1.623      -1.561
live_host_Yes                       0.8425      0.013     64.555      0.000       0.817       0.868
charity_tie_in_Yes                  0.2418      0.013     18.529      0.000       0.216       0.267
==============================================================================
Omnibus:                      584.467   Durbin-Watson:                   2.006
Prob(Omnibus):                  0.000   Jarque-Bera (JB):              394.485
Skew:                          -0.030   Prob(JB):                     2.18e-86
Kurtosis:                       2.647   Cond. No.                         10.8
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

Display the coefficient values of your model inputs.

I extract and display the model’s coefficients to understand the direction and strength of each feature’s impact on ratings.

coef_df = pd.DataFrame(regressor.coef_, X.columns, columns=['Coefficient'])
coef_df
Coefficient
headliner_Drone Light Show 0.017789
headliner_Fireworks 0.000853
headliner_Live Musical 1.029869
music_atmosphere_Pop Soundtrack -0.713001
show_length_70 0.717907
show_length_110 0.970937
show_length_150 0.703843
peak_dB_Level_85 0.016527
peak_dB_Level_105 -0.887189
crowding_index_60 0.223038
crowding_index_80 0.232322
crowding_index_100 -0.604624
viewing_zone_boardwalk 0.388865
viewing_zone_castle_courtyard 0.356038
viewing_zone_front plaza 0.338891
dining_bundle_dessert_party -0.002845
dining_bundle_meal_package 0.003259
dining_bundle_snack_voucher -0.092152
re_entry_One 0.296802
re_entry_Unlimited -1.592003
live_host_Yes 0.842467
charity_tie_in_Yes 0.241810

Write two-three paragraphs for Lobster Land management about what your model is showing you.

Dear Lobster Land Management Team,

I wanted to share some important findings from our conjoint analysis that can help guide decisions around evening entertainment programming. By examining guest survey responses across different features—headliner type, show length, sound levels, re-entry policies, and more—the analysis provides a clearer picture of what actually drives satisfaction and what might be holding the park back. The results show some strong patterns. Live Musical performances (coefficient of 1.03) and having a live host (0.84) make a significant positive impact on guest ratings, while unlimited re-entry (-1.59) and extremely loud sound at 105 dB (-0.89) actively hurt the experience. Shows running around 110 minutes (0.97) appear to be the ideal length, and high crowding levels (negative 0.60 at maximum capacity) clearly diminish satisfaction. These numbers help clarify not just what guests prefer, but how much each element matters when prioritizing investments and operational changes.

From a short-term planning perspective, there are several actionable takeaways. The 110-minute show format seems to be the best option where guests feel they’re getting a full experience without it dragging on or feeling rushed. Although the unlimited re-entry sounds captivating, guests actually prefer having just one opportunity to leave and return, likely because unlimited access signals overcrowding or diminishes the sense of exclusivity. A simple re-entry policy could make guests feel they’re getting more value while also helping manage crowds better. Live musicals clearly offer the best experiences, but they also come with higher costs, tighter schedules, and more moving parts than drone shows or fireworks. The team will need to think carefully about when and how often to host live performances, especially during busy seasons when multiple shows a night could stretch both staff and resources.

Looking at longer-term strategy, the data suggests some interesting opportunities for tiered experiences. The park should offer premium ticket packages which allow guests to book particular less crowded viewing spots within standard capacity areas. This allows guests who value personal space to pay for that comfort while offering more budget-conscious visitors access at lower price points. Live hosts and charity partnerships, though subtle in impact, show how genuine connection and purpose-driven storytelling can make experiences more memorable and unique. On the dining side, bundles don’t boost satisfaction much, and snack vouchers may even hurt it, as guests often see them as low-value add-ons rather than real enhancements.

The key take-away is: prioritize live performances with engaging hosts, keep 110-minute show lengths, keep the sound levels part of the experience without being too overpowering, and manage crowds so visitors have room to enjoy themselves. By focusing on what matters most to customers (authentic experiences, good pacing, and making the evening special), the park can differentiate and create the evenings guests will come back for.