Introduction

Between 2016 and 2017, Melbourne’s housing market experienced a period of rapid growth, due to strong population growth, low interest rates and high demand. Residential housing price rose by 13.8% from June 2016 to June 2017. (ABS, 2017)

However, by late 2017 and into 2018, the market began to cool down. This was partly due to regulatory interventions, where the Australian Prudential Regulation Authority (APRA) tightened lending standards and introduced limits on investor borrowing. (APRA, 2017)

This dataset, which includes property sales data from 2016 to 2018, captures this transitional period in Melbourne’s real estate market. It offers the opportunity to analyze how different property features, such as the number of rooms, land size, and proximity to the central business district (CBD) — influenced sale prices and property classifications during a time of significant economic and regulatory change.

Objectives

This project aims to explore and analyze the Melbourne Housing Market dataset (2016–2018) to gain insights and build predictive models. The two main objectives are:

1. Regression Task

To identify the best model in predicting house prices according to features such as number of rooms, land size, building area and distance from CBD. By using the best model, buyers and sellers can estimate fair market value, and real estate agents can improve their pricing strategies. 

2. Classification Task

To identify the best model in classifying properties into their property types, such as house, unit or townhouse. If implemented, this can help in better filtering and recommendations on property platforms, and improve accuracy in automated listings and valuation tools. 

Data Understanding

This project uses the publicly available Melbourne Housing Market dataset, sourced from Kaggle.(https://www.kaggle.com/datasets/anthonypino/melbourne-housing-market/data) The dataset contains 34857 property records with 21 features. The target variables would be “Price” (for regression) and “Type” (for classification)

# loading packages
library(dplyr)
library(ggplot2)
library(tidyr)
library(corrplot)
library(caret)
df <- read.csv("Melbourne_housing_FULL.csv", header = T)
dim(df)
## [1] 34857    21
head(df,5)
##       Suburb            Address Rooms Type   Price Method SellerG      Date
## 1 Abbotsford      68 Studley St     2    h      NA     SS  Jellis 3/09/2016
## 2 Abbotsford       85 Turner St     2    h 1480000      S  Biggin 3/12/2016
## 3 Abbotsford    25 Bloomburg St     2    h 1035000      S  Biggin 4/02/2016
## 4 Abbotsford 18/659 Victoria St     3    u      NA     VB  Rounds 4/02/2016
## 5 Abbotsford       5 Charles St     3    h 1465000     SP  Biggin 4/03/2017
##   Distance Postcode Bedroom2 Bathroom Car Landsize BuildingArea YearBuilt
## 1      2.5     3067        2        1   1      126           NA        NA
## 2      2.5     3067        2        1   1      202           NA        NA
## 3      2.5     3067        2        1   0      156           79      1900
## 4      2.5     3067        3        2   1        0           NA        NA
## 5      2.5     3067        3        2   0      134          150      1900
##          CouncilArea Lattitude Longtitude            Regionname Propertycount
## 1 Yarra City Council  -37.8014   144.9958 Northern Metropolitan          4019
## 2 Yarra City Council  -37.7996   144.9984 Northern Metropolitan          4019
## 3 Yarra City Council  -37.8079   144.9934 Northern Metropolitan          4019
## 4 Yarra City Council  -37.8114   145.0116 Northern Metropolitan          4019
## 5 Yarra City Council  -37.8093   144.9944 Northern Metropolitan          4019

An initial check on the data quality is conducted. Some insights from initial checking:

# Summary table for variables
summarize_df <- function(df) {
  summary_table <- tibble(
    ColumnName = names(df),
    TotalObservations = nrow(df),
    DataType = sapply(df, function(x) class(x)[1]),
    MissingValues = sapply(df, function(x) sum(is.na(x))),
    PercentMissing = sapply(df, function(x) sum(is.na(x)) / length(x) * 100)
  )
  return(summary_table)
}
print(summarize_df(df), n= Inf)
## # A tibble: 21 × 5
##    ColumnName    TotalObservations DataType  MissingValues PercentMissing
##    <chr>                     <int> <chr>             <int>          <dbl>
##  1 Suburb                    34857 character             0            0  
##  2 Address                   34857 character             0            0  
##  3 Rooms                     34857 integer               0            0  
##  4 Type                      34857 character             0            0  
##  5 Price                     34857 integer            7610           21.8
##  6 Method                    34857 character             0            0  
##  7 SellerG                   34857 character             0            0  
##  8 Date                      34857 character             0            0  
##  9 Distance                  34857 character             0            0  
## 10 Postcode                  34857 character             0            0  
## 11 Bedroom2                  34857 integer            8217           23.6
## 12 Bathroom                  34857 integer            8226           23.6
## 13 Car                       34857 integer            8728           25.0
## 14 Landsize                  34857 integer           11810           33.9
## 15 BuildingArea              34857 numeric           21115           60.6
## 16 YearBuilt                 34857 integer           19306           55.4
## 17 CouncilArea               34857 character             0            0  
## 18 Lattitude                 34857 numeric            7976           22.9
## 19 Longtitude                34857 numeric            7976           22.9
## 20 Regionname                34857 character             0            0  
## 21 Propertycount             34857 character             0            0
# checking for duplicate row 
sum(duplicated(df)) #1 duplicated row
## [1] 1
# Finding duplicated row to verify
df[duplicated(df)| duplicated(df, fromLast = T), ]
##           Suburb       Address Rooms Type Price Method SellerG       Date
## 15858 Nunawading 1/7 Lilian St     3    t    NA     SP  Jellis 17/06/2017
## 15859 Nunawading 1/7 Lilian St     3    t    NA     SP  Jellis 17/06/2017
##       Distance Postcode Bedroom2 Bathroom Car Landsize BuildingArea YearBuilt
## 15858     15.4     3131        3        3   2      405          226      2000
## 15859     15.4     3131        3        3   2      405          226      2000
##                   CouncilArea Lattitude Longtitude           Regionname
## 15858 Manningham City Council -37.82678   145.1678 Eastern Metropolitan
## 15859 Manningham City Council -37.82678   145.1678 Eastern Metropolitan
##       Propertycount
## 15858          4973
## 15859          4973
# checking for unrealistic values (negatives or zeroes)
df |> filter(Landsize < 0 | Rooms <=0 | Price <= 0) 
##  [1] Suburb        Address       Rooms         Type          Price        
##  [6] Method        SellerG       Date          Distance      Postcode     
## [11] Bedroom2      Bathroom      Car           Landsize      BuildingArea 
## [16] YearBuilt     CouncilArea   Lattitude     Longtitude    Regionname   
## [21] Propertycount
## <0 rows> (or 0-length row.names)
# Checking for unrealistic year built
df |> filter(YearBuilt<1800 | YearBuilt > 2018)
##           Suburb        Address Rooms Type   Price Method  SellerG       Date
## 1        Bulleen   3 Maringa St     4    h      NA     SP      Ray  7/11/2016
## 2 Mount Waverley 5 Armstrong St     3    h 1200000     VB  McGrath 24/06/2017
## 3      Bentleigh     1 Wyuna Ct     3    h 1100000     VB Woodards 17/03/2018
##   Distance Postcode Bedroom2 Bathroom Car Landsize BuildingArea YearBuilt
## 1     11.8     3105        4        2   2      729          255      2106
## 2     14.2     3149        3        1   4      807          117      1196
## 3     11.4     3204        3        1   4      635          242      2019
##               CouncilArea Lattitude Longtitude            Regionname
## 1 Manningham City Council -37.76370   145.0881  Eastern Metropolitan
## 2     Monash City Council -37.86788   145.1212  Eastern Metropolitan
## 3  Glen Eira City Council -37.92963   145.0367 Southern Metropolitan
##   Propertycount
## 1          4480
## 2         13366
## 3          6795

Data Cleaning

  1. Changing data types
df$Suburb <- as.factor(df$Suburb) #changing from character to factor
df$Type <- as.factor(df$Type) #changing from character to factor
df$Method <- as.factor(df$Method) #changing from character to factor
df$Date <- as.Date(df$Date) #changing from character to date
df$Distance <- as.numeric(df$Distance)  #changing from character to numeric
## Warning: NAs introduced by coercion
df$Postcode <- as.factor(df$Postcode) #changing from character to factor
df$Landsize <- as.numeric(df$Landsize) #changing from integer to numeric
df$CouncilArea <- as.factor(df$CouncilArea) #changing from character to factor
df$Regionname <- as.factor(df$Regionname) #changing from character to factor

sapply(df,class)
##        Suburb       Address         Rooms          Type         Price 
##      "factor"   "character"     "integer"      "factor"     "integer" 
##        Method       SellerG          Date      Distance      Postcode 
##      "factor"   "character"        "Date"     "numeric"      "factor" 
##      Bedroom2      Bathroom           Car      Landsize  BuildingArea 
##     "integer"     "integer"     "integer"     "numeric"     "numeric" 
##     YearBuilt   CouncilArea     Lattitude    Longtitude    Regionname 
##     "integer"      "factor"     "numeric"     "numeric"      "factor" 
## Propertycount 
##   "character"

Handling missing data

  1. Remove missing values from target variables. Type has no missing values so this is done only to Price.
df1 <- subset(df,(!is.na(df[,5])))
colSums(is.na(df1))
##        Suburb       Address         Rooms          Type         Price 
##             0             0             0             0             0 
##        Method       SellerG          Date      Distance      Postcode 
##             0             0             0             1             0 
##      Bedroom2      Bathroom           Car      Landsize  BuildingArea 
##          6441          6447          6824          9265         16591 
##     YearBuilt   CouncilArea     Lattitude    Longtitude    Regionname 
##         15163             0          6254          6254             0 
## Propertycount 
##             0
dim(df1)
## [1] 27247    21
  1. Handling missing data of Building Area by imputing with median to avoid skewness due to outliers
#Showing the outliers for Building Area
boxplot(df1$BuildingArea,
        main = "Building Area Boxplot",
        ylab = "Building Area (square meters)",
        col = "skyblue",
        border = "darkblue",
        notch = TRUE,
        outline = TRUE
        )

#We can see that there huge outliers. Hence why we choose to impute the missing values with median instead of mean

#Calculating median
median_buildingarea <-median(df$BuildingArea, na.rm = TRUE)

#Impute missing values with median
df1$BuildingArea[is.na(df1$BuildingArea)]  <- median_buildingarea

#Verify that no more missing values for Price remain
sum(is.na(df1$BuildingArea))
## [1] 0
  1. Handling missing data for Bedroom.
#Bedroom2 and Rooms seem  to share similar values. 
#A quick check via a simple scatterplot shows an almost perfectly linear relationship 
df_temp1 <- df1[!is.na(df1$Bedroom2),]

ggplot(data = df_temp1, aes(x = Bedroom2, y = Rooms)) +
  geom_point(alpha = 0.6) + 
  geom_smooth(method = "lm", se = TRUE, color = "lightgreen", fill = "red") + 
  labs(title = "Bedroom2 & Rooms Relationship", 
       x = "Number of Bedrooms (Bedroom2)", 
       y = "Number of Rooms") + 
  theme_minimal()
## `geom_smooth()` using formula = 'y ~ x'

#Hence, we will impute the values of Rooms into the missing values of Bedrooms2
my.na <- is.na(df1$Bedroom2)
df1$Bedroom2[my.na] <- df1$Rooms[my.na]
colSums(is.na(df1))
##        Suburb       Address         Rooms          Type         Price 
##             0             0             0             0             0 
##        Method       SellerG          Date      Distance      Postcode 
##             0             0             0             1             0 
##      Bedroom2      Bathroom           Car      Landsize  BuildingArea 
##             0          6447          6824          9265             0 
##     YearBuilt   CouncilArea     Lattitude    Longtitude    Regionname 
##         15163             0          6254          6254             0 
## Propertycount 
##             0
  1. For Bathrooms - it typically is related to how many bedrooms are in a house. We shall find out the average ratio of bathrooms to bedrooms and multiply this by a property’s number of bedrooms to get the number of bathrooms (rounded to nearest integer) and this will be imputed into the missing values
#Create subset of data without missing bathroom values
bathroom_noNA <- subset(df1, 
                     !is.na(Rooms) &
                       !is.na(Bathroom))

#Calculating the average ratio
bathroom_room_ratio <- bathroom_noNA$Bathroom / bathroom_noNA$Rooms
average_bathroom_room_ratio <- mean(bathroom_room_ratio, na.rm = TRUE)
print(paste("Average Bathroom to Room Ratio: ", round(average_bathroom_room_ratio, 3)))
## [1] "Average Bathroom to Room Ratio:  0.536"
#Identifying indices of rows with missing values
bathroom_NA <- which(is.na(df1$Bathroom))

#Imputing the missing values for Bathroom using the value of Rooms multiplied by the average ratio then rounded to the nearest whole number
df1$Bathroom[bathroom_NA] <- round(df1$Rooms[bathroom_NA]*average_bathroom_room_ratio)

#Checking change in missing values
colSums(is.na(df1))
##        Suburb       Address         Rooms          Type         Price 
##             0             0             0             0             0 
##        Method       SellerG          Date      Distance      Postcode 
##             0             0             0             1             0 
##      Bedroom2      Bathroom           Car      Landsize  BuildingArea 
##             0             0          6824          9265             0 
##     YearBuilt   CouncilArea     Lattitude    Longtitude    Regionname 
##         15163             0          6254          6254             0 
## Propertycount 
##             0
  1. For the features Car, Landsize, YearBuilt, Lattitude and Longtitude - we shall impute the missing values by using the median values of the features for each specific Suburb
#Imputing for Car
df1 <- df1 %>%
  group_by(Suburb) %>%
  mutate(Car = ifelse(is.na(Car),
                      median(Car, na.rm = TRUE), # Calculate median for the current Suburb group
                      Car)) %>%
  ungroup() 

#Imputing for Landsize
df1 <- df1 %>%
  group_by(Suburb) %>%
  mutate(Landsize = ifelse(is.na(Landsize),
                      median(Landsize, na.rm = TRUE), # Calculate median for the current Suburb group
                      Landsize)) %>%
  ungroup() 

#Imputing for YearBuilt
df1 <- df1 %>%
  group_by(Suburb) %>%
  mutate(YearBuilt = ifelse(is.na(YearBuilt),
                      median(YearBuilt, na.rm = TRUE), # Calculate median for the current Suburb group
                      YearBuilt)) %>%
  ungroup() 

#Imputing for Longtitude
df1 <- df1 %>%
  group_by(Suburb) %>%
  mutate(Longtitude = ifelse(is.na(Longtitude),
                      median(Longtitude, na.rm = TRUE), # Calculate median for the current Suburb group
                      Longtitude)) %>%
  ungroup() 

#Imputing for Lattitude
df1 <- df1 %>%
  group_by(Suburb) %>%
  mutate(Lattitude = ifelse(is.na(Lattitude),
                      median(Lattitude , na.rm = TRUE), # Calculate median for the current Suburb group
                      Lattitude)) %>%
  ungroup() 

#Checking change in missing values
colSums(is.na(df1))
##        Suburb       Address         Rooms          Type         Price 
##             0             0             0             0             0 
##        Method       SellerG          Date      Distance      Postcode 
##             0             0             0             1             0 
##      Bedroom2      Bathroom           Car      Landsize  BuildingArea 
##             0             0            70            78             0 
##     YearBuilt   CouncilArea     Lattitude    Longtitude    Regionname 
##           103             0            70            70             0 
## Propertycount 
##             0
  1. Removing remaining rows with missing values
df2 <- df1 %>%
  drop_na()

#Checking change in missing values
colSums(is.na(df2))
##        Suburb       Address         Rooms          Type         Price 
##             0             0             0             0             0 
##        Method       SellerG          Date      Distance      Postcode 
##             0             0             0             0             0 
##      Bedroom2      Bathroom           Car      Landsize  BuildingArea 
##             0             0             0             0             0 
##     YearBuilt   CouncilArea     Lattitude    Longtitude    Regionname 
##             0             0             0             0             0 
## Propertycount 
##             0
  1. Removing the rows which was duplicated and the rows with unrealistic YearBuilt
#Checking if the duplicated row still exists
sum(duplicated(df2))
## [1] 0
#Since no more duplicated row, then only the rows with unrealistic YearBuilt will be removed
df3 <- df2 %>%
  filter(YearBuilt >= 1800 & YearBuilt <= 2018)

dim(df2)
## [1] 27140    21
dim(df3)
## [1] 27138    21

Exploratory Data Analysis

The Exploratory Data Analysis (EDA) phase is crucial for understanding the underlying patterns, relationships, and anomalies within the dataset. This section aims to visualize distributions, identify correlations, and uncover insights that will guide subsequent model building.

1 Distribution of Target Variable (Sale Price)

An initial examination of the Price variable’s distribution is performed to understand its typical range and characteristics.

# Load necessary libraries for data manipulation and visualization
library(dplyr)
library(ggplot2)
library(tidyr)
library(corrplot)

# Load the cleaned dataset
df <- df3

# Plot the distribution of Property Prices
eda_p_price_dist <- ggplot(df, aes(x = Price)) +
  geom_histogram(binwidth = 50000, fill = "#1F77B4", color = "white", alpha = 0.8) +
  labs(title = "Distribution of Property Prices",
       x = "Price (AUD)",
       y = "Frequency") +
  theme_minimal() +
  theme(plot.title = element_text(hjust = 0.5),
        panel.border = element_rect(color = "black", fill = NA, linewidth = 1),
        panel.grid.major = element_line(color = "grey", linetype = "dotted"),
        panel.grid.minor = element_line(color = "lightgrey", linetype = "dotted"))
eda_p_price_dist

Analysis: The histogram shows a right-skewed distribution for property prices, with most properties in lower to mid-price ranges. This implies a significant concentration of affordable housing, while a small segment of high-value properties forms a longer tail. This skewness suggests that data transformation (e.g., log transformation) may be beneficial for improving linear model performance.

2 Regression Problem: Factors Influencing Property Prices

Question: Which factors significantly influence property prices in the Melbourne housing market?

2.1 Relationship between Number of Rooms and Price

# Group data by the number of rooms and calculate the median price for each group
rooms_price <- df %>%
  group_by(Rooms) %>%
  summarise(MedianPrice = median(Price, na.rm = TRUE),
            Count = n()) %>%
  filter(Count > 10) # Filtering out categories with insufficient observations
# Generate a bar plot illustrating the median property price by the number of rooms
eda_p_rooms_price <- ggplot(rooms_price, aes(x = as.factor(Rooms), y = MedianPrice)) +
  geom_bar(stat = "identity", fill = "#2CA02C", alpha = 0.8) +
  labs(title = "Median Property Price by Number of Rooms",
       x = "Number of Rooms",
       y = "Median Price (AUD)") +
  theme_minimal() +
  theme(plot.title = element_text(hjust = 0.5),
        panel.border = element_rect(color = "black", fill = NA, linewidth = 1))
eda_p_rooms_price

Analysis: A consistent positive correlation between the number of rooms and median property price is observed, indicating that increased functional space drives higher value. The prevalence of 3-4 room properties and their rising median prices suggest strong market demand for family-sized dwellings, where additional living areas significantly contribute to property value.

2.2 Correlation Matrix of Numerical Variables

A correlation matrix is utilized to provide a comprehensive understanding of the linear relationships among all numerical features and the target variable, Price.

# Select numerical columns for correlation analysis and remove rows with NA values
numerical_cols <- df %>%
  select(Price, Rooms, Bedroom2, Bathroom, Car, Landsize, BuildingArea, YearBuilt, Distance) %>%
  drop_na()

# Compute the Pearson correlation matrix for the selected numerical variables
cor_matrix <- cor(numerical_cols)

# Visualize the correlation matrix using a circle method
corrplot(cor_matrix, method = "circle", type = "upper",
         tl.col = "black", tl.srt = 45,
         addCoef.col = "black", number.cex = 0.7,
         main = "Correlation Matrix of Numerical Variables")

Analysis: The correlation matrix quantifies linear relationships. Strong positive correlations exist between Price and attributes like Rooms, Bedroom2, Bathroom, BuildingArea, and Landsize, affirming that physical dimensions and amenities are primary value drivers. Conversely, a negative correlation with Distance to CBD highlights the premium on prime urban locations. Year Built shows a weaker, indirect correlation, indicating its complex interplay with other property characteristics and market trends.

2.3 Price in Relation to Distance from CBD

eda_p_price_vs_distance <- ggplot(df, aes(x = Distance, y = Price)) +
  geom_point(alpha = 0.2, color = "#2CA02C") + # Use alpha for transparency to see data density
  geom_smooth(method = "lm", color = "red", se = FALSE) + # Add a linear model trend line
  scale_y_continuous(labels = scales::comma) + # Format y-axis labels to be more readable
  labs(title = "Property Price vs. Distance from CBD",
       x = "Distance from CBD (km)",
       y = "Price (in AUD)") +
  theme_minimal() +
  theme(plot.title = element_text(hjust = 0.5),
        panel.border = element_rect(color = "black", fill = NA, linewidth = 1))

eda_p_price_vs_distance

Analysis:The graph reveals a clear financial premium for properties close to the CBD, highlighting the trade-off buyers make between price and convenience. This confirms Distance will be a powerful predictor of property values in our regression model.

3 Classification Problem: Predicting Property Type

Question: What factors can help predict the Type of a property (House, Townhouse, or Unit)?

3.1 Property Type Distribution across Suburbs (Top 10 Suburbs by Listing Count)

Analyzing the geographical distribution of different property types offers valuable insights for classification purposes.

# Identify the top 10 suburbs based on property listing count
top_suburbs <- df %>%
  count(Suburb, sort = TRUE) %>%
  head(10) %>%
  pull(Suburb)

# Filter the dataset to include only the top suburbs and count property types within each
suburb_type_counts <- df %>%
  filter(Suburb %in% top_suburbs) %>%
  group_by(Suburb, Type) %>%
  summarise(Count = n(), .groups = 'drop') %>%
  mutate(Percentage = Count / sum(Count) * 100)

# Generate a stacked bar chart depicting the distribution of property types across the top 10 suburbs
eda_p_suburb_type <- ggplot(suburb_type_counts, aes(x = Suburb, y = Percentage, fill = Type)) +
  geom_bar(stat = "identity", position = "fill", color = "white") +
  labs(title = "Distribution of Property Types in Top 10 Suburbs",
       x = "Suburb",
       y = "Percentage",
       fill = "Property Type") +
  scale_fill_manual(values = c("h" = "#1F77B4", "t" = "#FF7F0E", "u" = "#2CA02C")) +
  theme_minimal() +
  theme(plot.title = element_text(hjust = 0.5),
        axis.text.x = element_text(angle = 45, hjust = 1),
        panel.border = element_rect(color = "black", fill = NA, linewidth = 1))
eda_p_suburb_type

Analysis: This chart illustrates significant variability in property type distribution across active Melbourne suburbs. Distinct patterns, from house-dominated to mixed or unit-centric areas, reflect the profound influence of local urban planning, historical development, and demographic housing demands. This geographical segmentation strongly indicates that Suburb is a critical contextual feature for property type classification, reflecting fundamental differences in land use and housing supply. #### 3.2 Price Distribution by Property Type

# Create a boxplot to visualize the distribution of property prices across different property types
eda_p_price_by_type <- ggplot(df, aes(x = Type, y = Price, fill = Type)) +
  geom_boxplot(outlier.shape = NA, alpha = 0.8) + # Outliers are excluded for clearer visualization
  coord_cartesian(ylim = quantile(df$Price, c(0.05, 0.95), na.rm = TRUE)) + # Y-axis limits are set to focus on the central data range
  labs(title = "Price Distribution by Property Type",
       x = "Property Type",
       y = "Price (AUD)") +
  scale_fill_manual(values = c("h" = "#FF7F0E", "t" = "#D62728", "u" = "#9467BD")) +
  theme_minimal() +
  theme(plot.title = element_text(hjust = 0.5),
        panel.border = element_rect(color = "black", fill = NA, linewidth = 1),
        legend.position = "none")
eda_p_price_by_type

Analysis: The boxplot clearly shows that detached houses (“h”) consistently command significantly higher median prices and broader price ranges than townhouses (“t”) and units (“u”). This pronounced price separation results from factors like land value, desirability, and inherent differences in living space and privacy. This strong discriminative power of price distribution is a robust indicator for property type classification, reflecting fundamental market valuations.

3.3 Rooms Distribution by Property Type

# Generate a boxplot to illustrate the distribution of the number of rooms across property types
eda_p_rooms_by_type <- ggplot(df, aes(x = Type, y = Rooms, fill = Type)) +
  geom_boxplot(outlier.shape = NA, alpha = 0.8) +
  labs(title = "Number of Rooms Distribution by Property Type",
       x = "Property Type",
       y = "Number of Rooms") +
  scale_fill_manual(values = c("h" = "#1F77B4", "t" = "#FF7F0E", "u" = "#2CA02C")) +
  theme_minimal() +
  theme(plot.title = element_text(hjust = 0.5),
        panel.border = element_rect(color = "black", fill = NA, linewidth = 1),
        legend.position = "none")
eda_p_rooms_by_type

Analysis: Room count clearly differentiates property types. Houses (“h”) generally have more rooms, reflecting their larger footprint and family-oriented design. Townhouses (“t”) fall in an intermediate range, offering more space than units. Units (“u”), designed for compact urban living, consistently have the fewest rooms. This consistent pattern provides a strong structural indicator for distinguishing property types.

3.4 Landsize Distribution by Property Type

# Create a boxplot depicting the distribution of land size across property types
# Extreme outliers are filtered for improved visualization clarity
eda_p_landsize_by_type <- ggplot(df, aes(x = Type, y = Landsize, fill = Type)) +
  geom_boxplot(outlier.shape = NA, alpha = 0.8) +
  coord_cartesian(ylim = c(0, quantile(df$Landsize, 0.99, na.rm = TRUE))) + # Y-axis is limited to better view the data
  labs(title = "Landsize Distribution by Property Type",
       x = "Property Type",
       y = "Landsize (sqm)") +
  scale_fill_manual(values = c("h" = "#D62728", "t" = "#9467BD", "u" = "#8C564B")) +
  theme_minimal() +
  theme(plot.title = element_text(hjust = 0.5),
        panel.border = element_rect(color = "black", fill = NA, linewidth = 1),
        legend.position = "none")
eda_p_landsize_by_type

Analysis: Land size differences are highly significant for classification. Houses (“h”) unequivocally occupy substantially larger parcels, reflecting the preference for private land ownership. Townhouses (“t”) reside on smaller plots, balancing space with density. Units (“u”) are associated with minimal or no individual land. This stark contrast directly reflects fundamental structural and ownership characteristics, making Landsize a highly distinguishing feature.

Model Preparation and Evaluation

Model 1: Linear Regression vs Random Forest

set.seed(123)
library(caret)
library(lattice)
library(ranger)

# Load the dataset
df <- read.csv("cleaned_Melbourne_housing_FULL.csv")

# Delete unnecessary columns (such as Address, etc.)
df_model <- df[, c("Rooms", "Bathroom", "Car", "Landsize", "BuildingArea", "YearBuilt", "Price")]

# Remove missing values
df_model <- na.omit(df_model)

# Split training set and testing set
train_index <- createDataPartition(df_model$Price, p = 0.7, list = FALSE)
train_data <- df_model[train_index, ]
test_data <- df_model[-train_index, ]

# Training Control Settings
ctrl <- trainControl(method = "cv", number = 3)

# Train a linear regression model 
model_lm <- train(Price ~ ., data = train_data, method = "lm", trControl = ctrl)

# Train a Random Forest model using ranger
model_rf <- train(Price ~ ., data = train_data, method = "ranger", trControl = ctrl, importance = 'impurity')

# Predict
pred_lm <- predict(model_lm, newdata = test_data)
pred_rf <- predict(model_rf, newdata = test_data)

# Load Evaluation Metrics 
library(Metrics)

# Linear Regression Performance
lm_rmse <- rmse(test_data$Price, pred_lm)
lm_r2   <- R2(pred_lm, test_data$Price)

# Random Forest Performance
rf_rmse <- rmse(test_data$Price, pred_rf)
rf_r2   <- R2(pred_rf, test_data$Price)

# Output comparison table
data.frame(
  Model = c("Linear Regression", "Random Forest"),
  RMSE = c(lm_rmse, rf_rmse),
  R2 = c(lm_r2, rf_r2)
)
##               Model     RMSE        R2
## 1 Linear Regression 506921.5 0.3668723
## 2     Random Forest 425378.8 0.5547148
library(ggplot2)

ggplot(data = NULL, aes(x = test_data$Price, y = pred_lm)) +
  geom_point(alpha = 0.4) +
  geom_abline(color = "red") +
  labs(title = "Linear Regression Predicted vs Actual Prices",
       x = "Actual Price", y = "Predicted Price") +
  theme_minimal()

Random Forest: Predicted vs Actual Prices

ggplot(data = test_data, aes(x = Price, y = pred_rf)) +
  geom_point(alpha = 0.4) +
  geom_abline(color = "red") +
  labs(title = "Random Forest Predicted vs Actual Prices",
       x = "Actual Price", y = "Predicted Price") +
  theme_minimal()

Conclusion:This project establishes a regression model to predict housing price data in college student residential areas. By comparing the evaluation results of linear regression and random forest models, it is found that the random forest model outperforms linear regression in two main indicators - Root Mean Square Error (RMSE) and coefficient of determination (R²). The RMSE of the random forest model is 424,312, lower than the 506,922 of linear regression, indicating smaller prediction errors. Meanwhile, the R² value reaches 0.557, showing that it can better explain the underlying mechanisms of housing price changes. Therefore, the random forest model is more suitable as the core prediction model for this project.

Model 2: Random Forest vs Gradient Boosting Machine (GBM)

# Load libraries
set.seed(123)
library(caret)
library(randomForest)
library(gbm)
library(Metrics)
library(ggplot2)

# Load the dataset
df <- read.csv("cleaned_Melbourne_housing_FULL.csv")

# Select relevant features for regression
df_model <- df[, c("Rooms", "Bathroom", "Car", "Landsize", "BuildingArea", "YearBuilt", "Price")]
df_model <- na.omit(df_model)

# Split into training and testing sets (70/30 split)
train_index <- createDataPartition(df_model$Price, p = 0.7, list = FALSE)
train_data <- df_model[train_index, ]
test_data  <- df_model[-train_index, ]

# Training control settings
ctrl <- trainControl(method = "cv", number = 3)

# Train Random Forest model
model_rf <- train(Price ~ ., data = train_data, method = "ranger", trControl = ctrl)

# Train Gradient Boosting model
model_gbm <- train(Price ~ ., data = train_data, method = "gbm", verbose = FALSE, trControl = ctrl)

# Predictions
pred_rf <- predict(model_rf, newdata = test_data)
pred_gbm <- predict(model_gbm, newdata = test_data)

# Evaluation: RMSE and R-squared
rf_rmse <- rmse(test_data$Price, pred_rf)
rf_r2   <- R2(pred_rf, test_data$Price)

gbm_rmse <- rmse(test_data$Price, pred_gbm)
gbm_r2   <- R2(pred_gbm, test_data$Price)

# Comparison table
results <- data.frame(
  Model = c("Random Forest", "Gradient Boosting"),
  RMSE = c(rf_rmse, gbm_rmse),
  R2   = c(rf_r2, gbm_r2)
)
print(results)
##               Model     RMSE        R2
## 1     Random Forest 424474.1 0.5569621
## 2 Gradient Boosting 446912.3 0.5098354
# Graphical representation
# Random Forest
ggplot(data = NULL, aes(x = test_data$Price, y = pred_rf)) +
  geom_point(alpha = 0.4) +
  geom_abline(color = "red") +
  labs(title = "Random Forest: Predicted vs Actual Prices",
       x = "Actual Price", y = "Predicted Price") +
  theme_minimal()

# Gradient Boosting
ggplot(data = NULL, aes(x = test_data$Price, y = pred_gbm)) +
  geom_point(alpha = 0.4) +
  geom_abline(color = "red") +
  labs(title = "Gradient Boosting: Predicted vs Actual Prices",
       x = "Actual Price", y = "Predicted Price") +
  theme_minimal()

Conclusion for model 2: By comparing both results of Random Forest and Gradient Boosting (GBM), it shows that both models are suitable in predicting housing price. However, the result shows that Random Forest have slightly better performance on this data set with a lower RMSE(424517.6) and Higher R²(0.5567944).Therefore, Random Forest is more preferred model in house price prediction.

Property Type Classification

Comparison 1: Random Forest vs XGBoost

# Load libraries
set.seed(123)
library(caret)
library(randomForest)
library(gbm)
library(Metrics)
library(ggplot2)

# Select relevant features for regression
df_model <- df[, c("Type","Rooms", "Bathroom", "Car", "Landsize", "BuildingArea", "YearBuilt")]
df_model <- na.omit(df_model)

# Specifying the target variable
df_model$Type <- as.factor(df_model$Type)


# Split into training and testing sets (70/30 split)
train_index <- createDataPartition(df_model$Type, p = 0.7, list = FALSE)
train_data <- df_model[train_index, ]
test_data  <- df_model[-train_index, ]

# Training control settings
ctrl <- trainControl(method = "cv", number = 3)

# Train Random Forest model
model_rf <- train(Type ~ ., data = train_data, method = "ranger", trControl = ctrl, importance = 'impurity')

# Train Gradient Boosting model
model_gbm <- train(Type ~ ., data = train_data, method = "gbm", verbose = FALSE, trControl = ctrl)

# Predictions
pred_rf <- predict(model_rf, newdata = test_data)
pred_gbm <- predict(model_gbm, newdata = test_data)

# Evaluation: RMSE and R-squared
cm_rf <- confusionMatrix(pred_rf, test_data$Type)
cm_gbm <- confusionMatrix(pred_gbm, test_data$Type)

# Comparison table
results <- data.frame(
  Model = c("Random Forest", "Gradient Boosting"),
  Accuracy = c(cm_rf$overall["Accuracy"], cm_gbm$overall["Accuracy"]),
  Kappa = c(cm_rf$overall["Kappa"], cm_gbm$overall["Kappa"])
)
print(results)
##               Model  Accuracy     Kappa
## 1     Random Forest 0.8109337 0.5812594
## 2 Gradient Boosting 0.8044226 0.5582174

Conclusion for model comparison 1: By comparing both results of Random Forest and Gradient Boosting (GBM), it shows that both Random Forest is more suitable in the classification of property types. The result shows that Random Forest have slightly better performance on this data set with a higher Accuracy: 0.8109337 and Kappa: 0.5812594.Therefore, Random Forest is more preferred model in property type classification.

Variable Importance

# Compute variable importance
importance_rf <- varImp(model_rf)

# Print top features
print(importance_rf)
## ranger variable importance
## 
##              Overall
## Rooms        100.000
## Landsize      84.354
## YearBuilt     33.471
## BuildingArea  17.502
## Car            5.055
## Bathroom       0.000
# Plot variable importance
plot(importance_rf, main = "Variable Importance - Random Forest")

The most important features in property type prediction are ‘Rooms’ and ‘Landsize’.

Comparison 2: Random Forest vs Decision Tree

# Load libraries
set.seed(123)
library(caret)
library(randomForest)
library(gbm)
library(ggplot2)

# Select features
df_model <- df[, c("Type", "Rooms", "Bathroom", "Car", "Landsize", "BuildingArea", "YearBuilt")]
df_model <- na.omit(df_model)

# Set target as factor
df_model$Type <- as.factor(df_model$Type)

# Train/test split
train_index <- createDataPartition(df_model$Type, p = 0.7, list = FALSE)
train_data <- df_model[train_index, ]
test_data  <- df_model[-train_index, ]

# Training control
ctrl <- trainControl(method = "cv", number = 3)

# Train models
model_rf <- train(Type ~ ., data = train_data, method = "ranger", trControl = ctrl)
model_dt <- train(Type ~ ., data = train_data, method = "rpart", trControl = ctrl)

# Predictions
pred_rf <- predict(model_rf, newdata = test_data)
pred_dt <- predict(model_dt, newdata = test_data)

# Evaluation using Confusion Matrix
cm_rf <- confusionMatrix(pred_rf, test_data$Type)
cm_dt <- confusionMatrix(pred_dt, test_data$Type)

# Print overall accuracy
results <- data.frame(
  Model = c("Random Forest", "Decision Tree"),
  Accuracy = c(cm_rf$overall["Accuracy"], cm_dt$overall["Accuracy"]),
  Kappa = c(cm_rf$overall["Kappa"], cm_dt$overall["Kappa"])
)
print(results)
##           Model  Accuracy     Kappa
## 1 Random Forest 0.8109337 0.5812594
## 2 Decision Tree 0.7831695 0.4837227

Conclusion for model comparison 2: By comparing both results of Random Forest and Decision Tree, it shows that both Random Forest is more suitable in the classification of property types. The result shows that Random Forest have slightly better performance on this data set with a higher Accuracy: 0.8109337 and Kappa: 0.5812594.Therefore, Random Forest is more preferred model in property type classification.

# Print importance for the decision tree
importance_dt <- varImp(model_dt)
print(importance_dt)
## rpart variable importance
## 
##              Overall
## Rooms        100.000
## Landsize      53.967
## Car           19.594
## BuildingArea   7.738
## Bathroom       6.371
## YearBuilt      0.000

The most important features in property type prediction are ‘Rooms’ and ‘Landsize’.

Hyperparameter Tuning of XGBoost to improve model performance

# Load libraries
library(tidymodels)
library(xgboost)

# Ensure target is a factor
train_data$Type <- as.factor(train_data$Type)
test_data$Type <- as.factor(test_data$Type)

# Preprocessing recipe
xgb_recipe <- recipe(Type ~ ., data = train_data) %>%
  step_zv(all_predictors()) %>%
  step_normalize(all_numeric_predictors())

# XGBoost model specification with tuning parameters
xgb_spec <- boost_tree(
  mode = "classification",
  trees = 200,
  tree_depth = tune(),
  learn_rate = tune(),
  loss_reduction = tune(),
  mtry = tune(),
  sample_size = tune()
) %>%
  set_engine("xgboost")

# Combine recipe and model into a workflow
xgb_workflow <- workflow() %>%
  add_recipe(xgb_recipe) %>%
  add_model(xgb_spec)

# 3-fold cross-validation
set.seed(123)
folds <- vfold_cv(train_data, v = 3)

# Tuning grid using Latin Hypercube Sampling
xgb_grid <- grid_latin_hypercube(
  tree_depth(range = c(2, 10)),
  learn_rate(range = c(0.01, 0.3)),
  loss_reduction(range = c(0, 5)),
  mtry = finalize(mtry(), train_data),
  sample_size = sample_prop(range = c(0.5, 1)),
  size = 20
)

# Tune model with CV
set.seed(123)
xgb_tuned <- tune_grid(
  xgb_workflow,
  resamples = folds,
  grid = xgb_grid,
  control = control_grid(save_pred = TRUE)
)

# Select best performing model based on accuracy
best_params <- select_best(xgb_tuned, metric = "accuracy")

# Finalize model with best parameters
final_xgb <- finalize_workflow(xgb_workflow, best_params)

# Fit model on full training data
final_model <- fit(final_xgb, data = train_data)

# Predict on test data
xgb_preds <- predict(final_model, new_data = test_data) %>%
  bind_cols(test_data)

# Confusion matrix
conf_matrix <- conf_mat(xgb_preds, truth = Type, estimate = .pred_class)
print(conf_matrix)
##           Truth
## Prediction    h    t    u
##          h 5030  425  347
##          t  199  264   85
##          u  289  168 1333
# Evaluation metrics: Accuracy and Kappa
xgb_results <- metrics(xgb_preds, truth = Type, estimate = .pred_class)
accuracy_val <- xgb_results %>% filter(.metric == "accuracy") %>% pull(.estimate)
kappa_val <- xgb_results %>% filter(.metric == "kap") %>% pull(.estimate)

# Create result frame 
xgb_output <- data.frame(
  Model = "XGBoost",
  Accuracy = round(accuracy_val, 7),
  Kappa = round(kappa_val, 7)
)

print(xgb_output)
##     Model  Accuracy     Kappa
## 1 XGBoost 0.8141278 0.5977218

Overall Findings

  1. Property prices in Melbourne are skewed toward the lower-to-mid range, with high-value properties concentrated among the affluent segment.

  2. A positive correlation exists between the number of rooms and property prices, reflecting the needs of larger families requiring more personal space.

  3. A negative correlation between property prices and distance from the CBD confirms that urban properties are more expensive due to better accessibility and amenities.

  4. Suburban areas exhibit variability in property types, with some areas dominated by houses and others by units.

  5. Property characteristics such as the number of rooms and land size follow this pattern across types:Units < Townhouses < Houses

  6. The Random Forest model performed best in predicting property prices, by achieving RMSE of 425378.8 and R²: 0.5547148

    Model Comparison Table:

    Model RMSE
    Random Forest 425378.8 0.5547148
    Linear Regression 506921.5 0.3668723
    Gradient Boosting 446912.3 0.5098354
  7. Random Forest also outperformed Gradient Boosting and Decision Tree in the classification of property types, by achieving accuracy of 0.8109337 and Kappa of 0.5812594.

    Model Comparison Table:

    Model Accuracy Kappa
    Random Forest 0.8109337 0.5812594
    Gradient Boosting 0.8044226 0.5582174
    Decision Tree 0.7831695 0.4837227
    Gradient Boosting (Tuned) 0.8141278 0.5977218
  8. Hyperparameter Tuning of the XGBoost model shows slight improvement in the Accuracy-Score and Kappa-Score (0.8141278, 0.5977218), surpassing Random Forest.


Conclusion

Melbourne offers a wide range of affordable housing options in the lower-to-mid price segment. Property prices tend to increase with the number of rooms and land area, but decrease as the distance from the CBD increases. Among the evaluated models, Random Forest is the most effective for both predicting property prices based on house features and the classification of property types based on house features. However, hyperparamter tuning improved the Accuracy-Score and Kappa-Score of XGBoost, surpassing Random Forest.