NYC Airbnb EDA

Exploratory Data Analysis of 48,895 Listings in Python

Scatter plot of NYC Airbnb listings colored by borough Scatter plot of NYC Airbnb listings colored by price

About the Project

This project is a full exploratory data analysis of the AB_NYC_2019 dataset—48,895 Airbnb listings across all five NYC boroughs, with 16 columns covering location, room type, price, minimum nights, review activity, and availability. Working in a Jupyter notebook with pandas, NumPy, matplotlib, and seaborn, I cleaned the data, removed unrealistic outliers, and built a series of visualizations to answer questions about where listings cluster, what drives price, and who the busiest hosts really are.

Analysis Highlights

Each stage of the analysis built on the cleaned dataset and ended in a visualization:

Data Cleaning & Outlier Removal

Missing values were concentrated in last_review and reviews_per_month (about 20% of rows), so rather than dropping a fifth of the dataset I treated those as "no reviews". Histograms exposed a long right tail in both price and minimum nights, so I filtered to realistic bounds—removing only 264 rows out of 48,895.

df_clean = df[
    (df['price'] >= 1) & (df['price'] <= 1000) &
    (df['minimum_nights'] >= 1) & (df['minimum_nights'] <= 365)
].copy()
# Original rows: 48,895 -> Cleaned rows: 48,631
Price and minimum nights distributions Long right tails in price and minimum nights

Neighbourhood Price Rankings

Grouping by borough and neighbourhood—and keeping only neighbourhoods with more than 5 listings—I pulled the top 5 and bottom 5 neighbourhoods by average price within each borough, 50 in total. At the borough level, Manhattan leads on average price, followed by Brooklyn, with Queens, the Bronx, and Staten Island noticeably cheaper.

neighbourhood_stats = (
    df_clean
    .groupby(['neighbourhood_group', 'neighbourhood'])
    .agg(avg_price=('price', 'mean'), listings_count=('id', 'count'))
    .reset_index()
)
Average Airbnb price by borough Manhattan and Brooklyn lead on price

Correlation Analysis

A Pearson correlation heatmap across six numeric features showed the strongest relationship between number_of_reviews and reviews_per_month (about 0.55), with weaker positive links between host listing counts, availability, and review activity. Notably, price shows almost no linear relationship with any other feature—a useful finding, since it means price cannot be explained by these variables alone.

Pearson correlation heatmap Review activity correlates; price does not

Geography, Price, and Reviews

Plotting latitude and longitude recreates the shape of New York City from listing density alone. Colored by borough, Manhattan and Brooklyn appear as dense cores while Queens, the Bronx, and Staten Island spread thin; re-coloring the same points by price makes the Manhattan premium obvious. Comparing price against review counts adds the other half of the story—heavily reviewed listings sit at moderate prices, while expensive listings collect far fewer reviews.

Price versus number of reviews scatter plot Price vs. number of reviews

Text Analysis with Word Clouds

Joining all 48,631 listing names into a single string and generating a word cloud (with "nyc", "new", "york", and "airbnb" added to the stopword list) surfaced how hosts market their spaces. Words like "bedroom", "room", and "private" dominate, showing that room type and privacy are the headline selling points.

stopwords = set(STOPWORDS)
stopwords.update(["nyc", "new", "york", "airbnb"])

wordcloud = WordCloud(
    width=800, height=400, background_color="white",
    stopwords=stopwords, collocations=False
).generate(text)
Word cloud of Airbnb listing names Most common words in listing names

Who Actually Hosts in NYC

Ranking hosts by listing count revealed that the busiest accounts are not individuals at all— Sonder (NYC) and Blueground each manage around 100 or more properties, far above the typical host with one or two. The top of the NYC market is effectively corporate operators running large portfolios.

Top 10 hosts by number of listings Top 10 hosts by listing count

Market Concentration by Borough

Manhattan and Brooklyn each carry roughly 20,000 listings, Queens around 5,000, and the Bronx and Staten Island barely register—Airbnb activity in NYC is heavily concentrated in the tourist-facing boroughs.

Total listings by borough Total listings by borough

Room Type Mix

Breaking listings down by room type within each borough showed a clear split: entire home/apartment listings dominate Manhattan and Brooklyn, while private rooms outnumber whole apartments in Queens and the Bronx—a real difference in how hosting works outside the tourist core.

Room type counts by borough Room type counts by borough

Technologies Used

Python pandas NumPy matplotlib seaborn WordCloud Jupyter Notebook

World Happiness Analysis

Five Years of Data, Three Models, One Custom Happiness Formula

About the Project

This project asks what actually makes people in different countries happy, using World Happiness Report data from 2015 through 2019. I merged five separately formatted yearly datasets into one panel of 782 country-years, explored how happiness relates to GDP, social support, health, freedom, generosity, and corruption, then trained three machine learning models on 2015–2018 to predict the 2019 rankings. I finished by writing my own weighted happiness formula and comparing its ranking against the official one.

Technical Highlights

The work moved from messy source files, through EDA, to model comparison:

Standardizing & Merging Five Datasets

Each year used different column names for the same variables (Happiness Rank, Happiness.Rank, and Overall rank all mean the same thing). I renamed every file to a shared schema, added a Year column, and used reindex so missing columns became NaN instead of breaking the concatenation. The result is a single tidy DataFrame of 782 rows and 11 columns.

common_cols = ["Country", "Year", "Rank", "Score", "GDP",
               "Social_support", "Health", "Freedom",
               "Generosity", "Corruption", "Region"]

df_all = pd.concat(
    [df.reindex(columns=common_cols) for df in yearly_frames],
    ignore_index=True
)

Rank Stability Over Time

Restricting to countries present in all five years, I pivoted ranks into a country-by-year table and measured both the standard deviation of each rank (stability) and the 2015 to 2019 change (improvement). New Zealand, Australia, Iceland, Denmark, and the Netherlands barely move at all, while Benin, Ivory Coast, Honduras, Hungary, and Gabon climbed the most over the five-year window.

rank_stats = df_panel.pivot_table(
    index="Country", columns="Year", values="Rank"
)

rank_stats["rank_std"] = df_panel.groupby("Country")["Rank"].std()
rank_stats["rank_change"] = rank_stats["Rank_2015"] - rank_stats["Rank_2019"]

What Correlates with Happiness

Scatter plots and a correlation matrix pointed to three dominant predictors: GDP per capita (r ≈ 0.79), social support (r ≈ 0.75), and healthy life expectancy (r ≈ 0.70). Freedom follows at roughly 0.5, while corruption (about 0.43) and generosity (about 0.16) matter far less. Average world happiness itself stayed remarkably flat across the period, moving only between about 5.35 and 5.41.

Model 1: Linear Regression (Baseline)

Training on 2015–2018 and testing on 2019 gave a mean absolute error of 0.42 and an R² of 0.75, translating to an average error of about 15–16 places when predicted scores are converted back into rankings. A clean, interpretable benchmark, but with visible spread among the lowest-scoring countries.

feature_cols = ["GDP", "Social_support", "Health",
                "Freedom", "Generosity", "Corruption"]

train_df = df_all[df_all["Year"] < 2019]   # 2015-2018
test_df  = df_all[df_all["Year"] == 2019]  # held-out year

linreg = LinearRegression().fit(X_train, y_train)
# MAE: 0.42 | R2: 0.75 | mean rank error: ~15

Model 2: Random Forest

The flexible, non-linear model actually performed worse than the baseline—MAE 0.48, R² 0.71, average rank error about 17. With only around 150 countries per year and relationships that are close to linear to begin with, the extra capacity of an ensemble of trees had nothing to exploit. A useful reminder that a more complex model is not automatically a better one.

rf = RandomForestRegressor(
    n_estimators=200,
    max_depth=5,
    min_samples_leaf=3
).fit(X_train, y_train)
# MAE: 0.48 | R2: 0.71 | mean rank error: ~17

Model 3: KNN Regression (Best)

Distance-weighted k-nearest neighbours with k = 10 won outright: MAE 0.38, R² 0.80, and an average rank error of about 14 places. Predicting a country from the countries most similar to it in feature space beat both alternatives, though all three models struggled with the same low-scoring countries.

knn = KNeighborsRegressor(
    n_neighbors=10,
    weights="distance"
).fit(X_train, y_train)
# MAE: 0.38 | R2: 0.80 | mean rank error: ~14

A Personal Happiness Formula

Finally, I built my own weighted score that leans harder on social support and health than the official index does, and re-ranked the 2019 countries with it. The shifts are dramatic in places: Singapore jumps from 34th to 1st, while Finland drops from 1st to 10th. Countries with strong health and economic profiles relative to their reported score gain the most.

custom_2019["MyScore"] = (
    0.20 * custom_2019["GDP"] +
    0.25 * custom_2019["Social_support"] +
    0.25 * custom_2019["Health"] +
    0.15 * custom_2019["Freedom"] +
    0.05 * custom_2019["Generosity"] +
    0.10 * custom_2019["Corruption"]
)

custom_2019["MyRank"] = custom_2019["MyScore"].rank(
    ascending=False, method="first"
).astype(int)

Conclusions

Across both the EDA and the models, social support, life expectancy, and GDP per capita consistently came out as the strongest numeric drivers of national happiness, with freedom playing a secondary role and generosity and corruption contributing comparatively little. If the goal were to raise a country’s happiness, the data points squarely at strengthening the social safety net and expanding access to healthcare before anything else.

Technologies Used

Python pandas NumPy scikit-learn matplotlib seaborn Linear Regression Random Forest KNN Regression Jupyter Notebook

Linear Regression Analysis

Multiple Imputation & Lack-of-Fit Testing in R

About the Project

This project explores the relationship between an independent variable (IV) and dependent variable (DV) through two distinct analytical approaches: multiple imputation for missing data (Part A) and transformation with lack-of-fit testing (Part B). Both analyses were conducted in RStudio using statistical packages like mice and alr3.

Part A: Multiple Imputation & Linear Regression

The first analysis tackled a common real-world problem: missing data. Rather than simply deleting incomplete cases (which can bias results), I used the mice package to perform multiple imputation—a statistically rigorous method that estimates missing values based on observed data patterns.

Data Preparation & Merging

The dataset was split across two CSV files (one for IV, one for DV), requiring a merge operation by subject ID. This is common in research settings where different measurements are collected separately.

PartA_IV <- read.csv("data/partA_IV.csv", header = TRUE)
PartA_DV <- read.csv("data/partA_DV.csv", header = TRUE)
PartA <- merge(PartA_IV, PartA_DV, by = "ID")

Exploring & Visualizing Missingness

Before imputation, I examined the missing data patterns using md.pattern(). This revealed that of 574 total observations, 449 had complete data, while 60 were missing DV values, 59 were missing IV values, and 6 were missing both. Understanding these patterns is crucial for choosing an appropriate imputation method.

any(is.na(PartA$IV))  # Check for missing IV
any(is.na(PartA$DV))  # Check for missing DV
md.pattern(PartA)     # Visualize missingness patterns

Multiple Imputation with MICE

The MICE (Multivariate Imputation by Chained Equations) algorithm was used with the norm.boot method, which assumes normal distributions and uses bootstrapping for uncertainty estimation. This creates multiple plausible datasets, accounting for the uncertainty inherent in imputed values.

# Keep rows with at least one observed value
PartA_imp <- PartA[!is.na(PartA$IV) | !is.na(PartA$DV), ]

# Run multiple imputation
imp <- mice(PartA_imp, method = "norm.boot", printFlag = FALSE)

# Extract completed dataset
PartA_complete <- complete(imp)

Linear Model & Results

With the complete dataset, I fit a simple linear regression model: DV ~ IV. The results were striking—the model explained 73.4% of the variance (adjusted R² = 0.7347), with a highly significant F-statistic of 1571 (p < 2.2e-16).

M <- lm(DV ~ IV, data = PartA_complete)
summary(M)
# Slope: 5.16 | Intercept: 28.36
# 95% CI for IV: [4.90, 5.41]

The estimated slope of 5.16 means that for every 1-unit increase in IV, DV increases by approximately 5.16 units. The narrow 95% confidence interval (4.90 to 5.41) confirms the precision of this estimate.

Part B: Transformation & Lack-of-Fit Testing

The second analysis addressed a different challenge: what if the relationship between IV and DV isn't naturally linear? This part used variable transformation and data binning to linearize the relationship, then verified the model fit using a formal lack-of-fit test.

Power Transformation

To stabilize variance and linearize the relationship, I applied a y^(-2/3) power transformation to the dependent variable. This is a common technique when the original data shows curvature or heteroscedasticity (non-constant variance).

data_trans <- data.frame(
  xtrans = data$x,
  ytrans = data$y^(-2/3)
)

Binning Strategy

For the lack-of-fit test, I needed replicate observations at each x-value. Since the data had continuous x-values, I created bins of width 0.3 using cut(), then computed group means. This allowed the pureErrorAnova() function to separate pure error from lack-of-fit.

breaks <- c(-Inf, seq(min(xtrans), max(xtrans), by = 0.3), Inf)
groups <- cut(data_trans$xtrans, breaks = breaks)
x_group_mean <- ave(data_trans$xtrans, groups)

Pure Error ANOVA Results

The pureErrorAnova() function from the alr3 package decomposes residuals into pure error (variation within bins) and lack-of-fit (systematic deviation from linearity). The key result: p = 0.5504 for lack-of-fit, which is not significant—meaning the linear model adequately fits the transformed data.

fit_b <- lm(y ~ x, data = data_bin)
pureErrorAnova(fit_b)
# Lack of fit: F = 0.3571, p = 0.5504 (not significant)
# Regression: F = 332.07, p < 2e-16 (highly significant)

The regression F-value of 332.07 (p < 2e-16) confirms a strong linear association after transformation, while the non-significant lack-of-fit test validates that the linear model is appropriate.

Key Statistical Concepts

Multiple Imputation MICE Algorithm Linear Regression ANOVA Confidence Intervals Power Transformation Data Binning Lack-of-Fit Test Pure Error ANOVA

Technologies Used

R RStudio mice alr3 knitr