::p_load(tmap, sf, tidyverse, knitr, spdep, sfdep, plotly, zoo, Kendall) pacman
Take-home_Ex1
Introduction
The success of Singapore’s public transport is commonly recognised as 1 of the quintessential indicator of its rapid growth and prosperity since her independence in 1965. It comprises of both public buses as well as mass rapid transport (MRT) trains. While the MRT network has been expanding across the many zones within Singapore, the bus network remains the most option accessible and reliable option amongst the two.
Thus, the utilisation pattern of buses are of key importance to multiple aspects of Singapore’s socio-economic spheres, ranging from areas of work productivity and efficiency, environmental impact, tourism, and even to potential impact to cost of living and real estate development; which underpins the impetus of this exercise to gain a better understanding of the utilisation patterns and characteristics of Singapore’s bus stops.
Objectives
The key objectives of this exercise are:
- To perform geovisualisation and analysis of peakhour busstop passenger trips
- To perform Analysis of Local Indicators of Spatial Association (LISA) for passenger trips
Getting Started
The code chunk below load the following packages:
- tmap: for thematic mapping
- sf for geospatial data handling
- tidyverse for non-spatial data handling.
- knitr for creating html table
- spdep
- sfdep for creating an sf and tidyverse friendly interfaceas well as for introducing new functionality that is not present in spdep
- plotly for interactive plots
Importing and preparing the Base Geospatial and Aspatial Data
Aspatial data
Firstly, the Passenger Volume by Origin Destination Bus Stops data set for the month of October 2023, from LTA DataMall, will be imported by using ‘read_csv()’ of **readr* package.
#\| eval: false
<- read.csv("data/aspatial/origin_destination_bus_202310.csv") odbus
From below, the values in OROGIN_PT_CODE and DESTINATON_PT_CODE odbus in the tibble data frame are in numeric data type.
glimpse(odbus)
Rows: 5,694,297
Columns: 7
$ YEAR_MONTH <chr> "2023-10", "2023-10", "2023-10", "2023-10", "2023-…
$ DAY_TYPE <chr> "WEEKENDS/HOLIDAY", "WEEKDAY", "WEEKENDS/HOLIDAY",…
$ TIME_PER_HOUR <int> 16, 16, 14, 14, 17, 17, 17, 7, 14, 14, 10, 20, 20,…
$ PT_TYPE <chr> "BUS", "BUS", "BUS", "BUS", "BUS", "BUS", "BUS", "…
$ ORIGIN_PT_CODE <int> 4168, 4168, 80119, 80119, 44069, 20281, 20281, 190…
$ DESTINATION_PT_CODE <int> 10051, 10051, 90079, 90079, 17229, 20141, 20141, 1…
$ TOTAL_TRIPS <int> 3, 5, 3, 5, 4, 1, 24, 2, 1, 7, 3, 2, 5, 1, 1, 1, 1…
Using appropriate tidyverse functions to convert these data values into factor data type.
$ORIGIN_PT_CODE <- as.factor(odbus$ORIGIN_PT_CODE)
odbus$DESTINATION_PT_CODE <- as.factor(odbus$DESTINATION_PT_CODE) odbus
From below, the values in OROGIN_PT_CODE and DESTINATON_PT_CODE odbus in the tibble data frame are now in factor data type.
glimpse(odbus)
Rows: 5,694,297
Columns: 7
$ YEAR_MONTH <chr> "2023-10", "2023-10", "2023-10", "2023-10", "2023-…
$ DAY_TYPE <chr> "WEEKENDS/HOLIDAY", "WEEKDAY", "WEEKENDS/HOLIDAY",…
$ TIME_PER_HOUR <int> 16, 16, 14, 14, 17, 17, 17, 7, 14, 14, 10, 20, 20,…
$ PT_TYPE <chr> "BUS", "BUS", "BUS", "BUS", "BUS", "BUS", "BUS", "…
$ ORIGIN_PT_CODE <fct> 4168, 4168, 80119, 80119, 44069, 20281, 20281, 190…
$ DESTINATION_PT_CODE <fct> 10051, 10051, 90079, 90079, 17229, 20141, 20141, 1…
$ TOTAL_TRIPS <int> 3, 5, 3, 5, 4, 1, 24, 2, 1, 7, 3, 2, 5, 1, 1, 1, 1…
Geospatial data
Import BusStop layer downloaded from LTA DataMall into RStudio and save it as a sf data frame called busstop.
<- st_read(dsn = "data/geospatial",
busstop layer = "BusStop") %>%
st_transform(crs = 3414)
Reading layer `BusStop' from data source
`C:\jayexx\ISSS624\Take_home_Exercises\Take_home_Ex1\data\geospatial'
using driver `ESRI Shapefile'
Simple feature collection with 5161 features and 3 fields
Geometry type: POINT
Dimension: XY
Bounding box: xmin: 3970.122 ymin: 26482.1 xmax: 48284.56 ymax: 52983.82
Projected CRS: SVY21
The structure of busstop sf tibble data frame is as follows.
glimpse(busstop)
Rows: 5,161
Columns: 4
$ BUS_STOP_N <chr> "22069", "32071", "44331", "96081", "11561", "66191", "2338…
$ BUS_ROOF_N <chr> "B06", "B23", "B01", "B05", "B05", "B03", "B02A", "B02", "B…
$ LOC_DESC <chr> "OPP CEVA LOGISTICS", "AFT TRACK 13", "BLK 239", "GRACE IND…
$ geometry <POINT [m]> POINT (13576.31 32883.65), POINT (13228.59 44206.38),…
Objective (1): Geovisualisation and Analysis of Bus Stop Passengers during Peakhours
Extracting the passenger trips for peakhour time intervals
For the purpose of this exercise, extract commuting flows during:
- weekday morning peak and label the output tibble data table as odbus_wkd6_9.
<- odbus %>%
odbus_wkd6_9 filter(DAY_TYPE == "WEEKDAY") %>%
filter(TIME_PER_HOUR >= 6 &
<= 9) %>%
TIME_PER_HOUR group_by(ORIGIN_PT_CODE) %>%
summarise(TRIPS = sum(TOTAL_TRIPS))
- weekday evening peak and label the output tibble data table as odbus_wkd17_20.
<- odbus %>%
odbus_wkd17_20 filter(DAY_TYPE == "WEEKDAY") %>%
filter(TIME_PER_HOUR >= 17 &
<= 20) %>%
TIME_PER_HOUR group_by(ORIGIN_PT_CODE) %>%
summarise(TRIPS = sum(TOTAL_TRIPS))
- weekend/holiday morning peak and label the output tibble data table as odbus_wke11_14.
<- odbus %>%
odbus_wke11_14 filter(DAY_TYPE == "WEEKENDS/HOLIDAY") %>%
filter(TIME_PER_HOUR >= 11 &
<= 14) %>%
TIME_PER_HOUR group_by(ORIGIN_PT_CODE) %>%
summarise(TRIPS = sum(TOTAL_TRIPS))
- weekend/holiday evening peak and label the output tibble data table as odbus_wke16_19.
<- odbus %>%
odbus_wke16_19 filter(DAY_TYPE == "WEEKENDS/HOLIDAY") %>%
filter(TIME_PER_HOUR >= 16 &
<= 19) %>%
TIME_PER_HOUR group_by(ORIGIN_PT_CODE) %>%
summarise(TRIPS = sum(TOTAL_TRIPS))
Convert and save the tible data sets in rds format as follows.
write_rds(odbus_wkd6_9, "data/rds/odbus_wkd6_9.rds")
write_rds(odbus_wkd17_20, "data/rds/odbus_wkd17_20.rds")
write_rds(odbus_wke11_14, "data/rds/odbus_wke11_14.rds")
write_rds(odbus_wke16_19, "data/rds/odbus_wke16_19.rds")
Import files back into R as follows.
<- read_rds("data/rds/odbus_wkd6_9.rds")
odbus_wkd6_9 <- read_rds("data/rds/odbus_wkd17_20.rds")
odbus_wkd17_20 <- read_rds("data/rds/odbus_wke11_14.rds")
odbus_wke11_14 <- read_rds("data/rds/odbus_wke16_19.rds") odbus_wke16_19
Preparing Geospatial data in Hexagonal grid
Creating hexagonal grid in sf format with grid_id as follows.
= st_make_grid(busstop, c(250, 250), what = "polygons", square = FALSE)
hxgn_grid
# To sf and add grid ID
= st_sf(hxgn_grid) %>%
hxgn_grid_sf mutate(grid_id = 1:length(lengths(hxgn_grid)))
Merging imported busstop data with Hexagonal grid as follows.
<- st_intersection(busstop, hxgn_grid_sf) %>%
busstop_hxgn_grid select(BUS_STOP_N, grid_id) %>%
st_drop_geometry()
Warning: attribute variables are assumed to be spatially constant throughout
all geometries
Checking for BUS_STOP_N with duplicate grid_id, and grid_id with duplicate BUS_STOP_N.
<- busstop_hxgn_grid %>%
check_duplicate group_by(grid_id) %>%
summarise(num_BUS_STOP_N = n_distinct(BUS_STOP_N))
<- busstop_hxgn_grid %>%
check_duplicate1 group_by(BUS_STOP_N) %>%
summarise(num_grid_id = n_distinct(grid_id))
From the above, - out of 3,131 grid_id, 1,476 (47%) grid_id contain 2 busstops, 1,410 (45%) grid_id contain only 1 busstop, and the remaining 8% containing more than 2 busstops. Only 1 grid_id has the maximum of 5 busstops. - out of 5,145 busstops, only 8 intercept across 2 grid_id.
Hence, for this exercise, to avoid double counting of trips subsequently, the duplicate grid_id for the 8 busstops are removed.
<- distinct(busstop_hxgn_grid, BUS_STOP_N, .keep_all = TRUE) busstop_hxgn_grid
Merging Peakhour Passenger Trips with Geospatial data
Weekday morning peak
Append the merged busstop_hxgn_grid frame onto odbus_wkd6_9 data frame as follows.
<- left_join(busstop_hxgn_grid, odbus_wkd6_9,
wkd6_9_hxgn_grid by = c("BUS_STOP_N" = "ORIGIN_PT_CODE")) %>%
rename(ORIGIN_BS = BUS_STOP_N,
ORIGIN_GRID = grid_id) %>%
group_by(ORIGIN_GRID) %>%
reframe(
MAX_TRIPS_BS = ORIGIN_BS[which.max(sum(TRIPS))],
TOT_TRIPS = sum(TRIPS),
AVG_TRIPS = mean(TRIPS)
)
As a good practice, check for duplicate records and retain duplicate records as follows.
<- wkd6_9_hxgn_grid %>%
duplicate group_by_all() %>%
filter(n()>1) %>%
ungroup()
<- unique(wkd6_9_hxgn_grid) wkd6_9_hxgn_grid
Merge resultant data with hexgon grid and filter as follows
<- left_join(hxgn_grid_sf,
wkd6_9_hxgn_grid_sf
wkd6_9_hxgn_grid,by = c("grid_id" = "ORIGIN_GRID"))%>%
select(MAX_TRIPS_BS, TOT_TRIPS, AVG_TRIPS, hxgn_grid)
= filter(wkd6_9_hxgn_grid_sf, TOT_TRIPS > 0) wkd6_9_hxgn_grid_sf
Weekday evening peak
Append the merged busstop_hxgn_grid frame onto odbus_wkd17_20 data frame as follows.
<- left_join(busstop_hxgn_grid, odbus_wkd17_20,
wkd17_20_hxgn_grid by = c("BUS_STOP_N" = "ORIGIN_PT_CODE")) %>%
rename(ORIGIN_BS = BUS_STOP_N,
ORIGIN_GRID = grid_id) %>%
group_by(ORIGIN_GRID) %>%
reframe(
MAX_TRIPS_BS = ORIGIN_BS[which.max(sum(TRIPS))],
TOT_TRIPS = sum(TRIPS),
AVG_TRIPS = mean(TRIPS)
)
As a good practice, check for duplicate records and retain duplicate records as follows.
<- wkd17_20_hxgn_grid %>%
duplicate1 group_by_all() %>%
filter(n()>1) %>%
ungroup()
<- unique(wkd17_20_hxgn_grid) wkd17_20_hxgn_grid
Merge resultant data with hexgon grid and filter as follows
<- left_join(hxgn_grid_sf,
wkd17_20_hxgn_grid_sf
wkd17_20_hxgn_grid,by = c("grid_id" = "ORIGIN_GRID"))%>%
select(MAX_TRIPS_BS, TOT_TRIPS, AVG_TRIPS, hxgn_grid)
= filter(wkd17_20_hxgn_grid_sf, TOT_TRIPS > 0) wkd17_20_hxgn_grid_sf
Weekend/holiday morning peak
Append the merged busstop_hxgn_grid frame onto odbus_wke11_14 data frame as follows.
<- left_join(busstop_hxgn_grid, odbus_wke11_14,
wke11_14_hxgn_grid by = c("BUS_STOP_N" = "ORIGIN_PT_CODE")) %>%
rename(ORIGIN_BS = BUS_STOP_N,
ORIGIN_GRID = grid_id) %>%
group_by(ORIGIN_GRID) %>%
reframe(
MAX_TRIPS_BS = ORIGIN_BS[which.max(sum(TRIPS))],
TOT_TRIPS = sum(TRIPS),
AVG_TRIPS = mean(TRIPS)
)
As a good practice, check for duplicate records and retain duplicate records as follows.
<- wke11_14_hxgn_grid %>%
duplicate2 group_by_all() %>%
filter(n()>1) %>%
ungroup()
<- unique(wke11_14_hxgn_grid) wke11_14_hxgn_grid
Merge resultant data with hexgon grid and filter as follows
<- left_join(hxgn_grid_sf,
wke11_14_hxgn_grid_sf
wke11_14_hxgn_grid,by = c("grid_id" = "ORIGIN_GRID"))%>%
select(MAX_TRIPS_BS, TOT_TRIPS, AVG_TRIPS, hxgn_grid)
= filter(wke11_14_hxgn_grid_sf, TOT_TRIPS > 0) wke11_14_hxgn_grid_sf
Weekend/holiday evening peak
Append the merged busstop_hxgn_grid frame onto odbus_wke16_19 data frame as follows.
<- left_join(busstop_hxgn_grid, odbus_wke16_19,
wke16_19_hxgn_grid by = c("BUS_STOP_N" = "ORIGIN_PT_CODE")) %>%
rename(ORIGIN_BS = BUS_STOP_N,
ORIGIN_GRID = grid_id) %>%
group_by(ORIGIN_GRID) %>%
reframe(
MAX_TRIPS_BS = ORIGIN_BS[which.max(sum(TRIPS))],
TOT_TRIPS = sum(TRIPS),
AVG_TRIPS = mean(TRIPS)
)
As a good practice, check for duplicate records and retain duplicate records as follows.
<- wke16_19_hxgn_grid %>%
duplicate3 group_by_all() %>%
filter(n()>1) %>%
ungroup()
<- unique(wke16_19_hxgn_grid) wke16_19_hxgn_grid
Merge resultant data with hexgon grid and filter as follows
<- left_join(hxgn_grid_sf,
wke16_19_hxgn_grid_sf
wke16_19_hxgn_grid,by = c("grid_id" = "ORIGIN_GRID"))%>%
select(MAX_TRIPS_BS, TOT_TRIPS, AVG_TRIPS, hxgn_grid)
= filter(wke16_19_hxgn_grid_sf, TOT_TRIPS > 0) wke16_19_hxgn_grid_sf
Geovisualisation of Busstop Peakhour periods
Plot the map with hexagonal grid as follows for:
- Weekday morning peak
tmap_mode("plot")
tmap mode set to plotting
tm_shape(wkd6_9_hxgn_grid_sf) +
tm_fill(
col = c("TOT_TRIPS", "AVG_TRIPS"),
palette = "Reds",
style = "quantile",
title = c("Total Passenger Trips during Weekday Morning Peak", "Average Passenger Trips during Weekday Morning Peak"),
id = "grid_id",
showNA = FALSE,
alpha = 0.6,
popup.vars = c(
"Total Trips: " = "TOT_TRIPS",
"Average Trips: " = "AVG_TRIPS",
"Most Popular Bus Stop: " = "MAX_TRIPS_BS"
),popup.format = list(
TOT_TRIPS = list(format = "f", digits = 0),
AVG_TRIPS = list(format = "f", digits = 0),
MAX_TRIPS_BS = list(format = "f", digits = 0)
)+
) tm_layout(main.title = "Bus Stop Passenger Trips during Weekday Morning Peak",
main.title.position = "center",
main.title.size = 1.2,
legend.height = 0.45,
legend.width = 0.35,
frame = TRUE) +
tm_borders(alpha = 0.5)
- Weekday evening peak
tmap_mode("plot")
tmap mode set to plotting
tm_shape(wkd17_20_hxgn_grid_sf) +
tm_fill(
col = c("TOT_TRIPS", "AVG_TRIPS"),
palette = "Reds",
style = "quantile",
title = c("Total Passenger Trips during Weekday Evening Peak", "Average Passenger Trips during Weekday Evening Peak"),
id = "grid_id",
showNA = FALSE,
alpha = 0.6,
popup.vars = c(
"Total Trips: " = "TOT_TRIPS",
"Average Trips: " = "AVG_TRIPS",
"Most Popular Bus Stop: " = "MAX_TRIPS_BS"
),popup.format = list(
TOT_TRIPS = list(format = "f", digits = 0),
AVG_TRIPS = list(format = "f", digits = 0),
MAX_TRIPS_BS = list(format = "f", digits = 0)
)+
) tm_layout(main.title = "Bus Stop Passenger Trips during Weekday Evening Peak",
main.title.position = "center",
main.title.size = 1.2,
legend.height = 0.45,
legend.width = 0.35,
frame = TRUE) +
tm_borders(alpha = 0.5)
- Weekend/holiday morning peak
tmap_mode("plot")
tmap mode set to plotting
tm_shape(wke11_14_hxgn_grid_sf) +
tm_fill(
col = c("TOT_TRIPS", "AVG_TRIPS"),
palette = "Reds",
style = "quantile",
title = c("Total Passenger Trips during Weekend Morning Peak", "Average Passenger Trips during Weekend Morning Peak"),
id = "grid_id",
showNA = FALSE,
alpha = 0.6,
popup.vars = c(
"Total Trips: " = "TOT_TRIPS",
"Average Trips: " = "AVG_TRIPS",
"Most Popular Bus Stop: " = "MAX_TRIPS_BS"
),popup.format = list(
TOT_TRIPS = list(format = "f", digits = 0),
AVG_TRIPS = list(format = "f", digits = 0),
MAX_TRIPS_BS = list(format = "f", digits = 0)
)+
) tm_layout(main.title = "Bus Stop Passenger Trips during Weekend Morning Peak",
main.title.position = "center",
main.title.size = 1.2,
legend.height = 0.45,
legend.width = 0.35,
frame = TRUE) +
tm_borders(alpha = 0.5)
- Weekend/holiday evening peak
tmap_mode("plot")
tmap mode set to plotting
tm_shape(wke16_19_hxgn_grid_sf) +
tm_fill(
col = c("TOT_TRIPS", "AVG_TRIPS"),
palette = "Reds",
style = "quantile",
title = c("Total Passenger Trips during Weekend Evening Peak", "Average Passenger Trips during Weekend Evening Peak"),
id = "grid_id",
showNA = FALSE,
alpha = 0.6,
popup.vars = c(
"Total Trips: " = "TOT_TRIPS",
"Average Trips: " = "AVG_TRIPS",
"Most Popular Bus Stop: " = "MAX_TRIPS_BS"
),popup.format = list(
TOT_TRIPS = list(format = "f", digits = 0),
AVG_TRIPS = list(format = "f", digits = 0),
MAX_TRIPS_BS = list(format = "f", digits = 0)
)+
) tm_layout(main.title = "Bus Stop Passenger Trips during Weekend Evening Peak",
main.title.position = "center",
main.title.size = 1.2,
legend.height = 0.45,
legend.width = 0.35,
frame = TRUE) +
tm_borders(alpha = 0.5)
Spatial patterns revealed by the geovisualisation
From the Geovisualisation of the 4 peak periods, it is noted that the number of trips during the evening is higher than morning for each day type. Further based on 9 weekend/holidays and 22 weekdays in October 2023, there is higher trips per day for weekdays as compared to weekdays.
Amongst the bus stops, the highest number of trip origins across the 4 peak periods are from the bus interchanges; while by region, the northwestern and southwestern region of Singapore seems to have the least number of trip origins across the 4 peak periods.
Making a comparison between distribution by Total trips and Average trips reveals whether there isn’t a very noticeable difference, and hence the areas with higher passenger trip origins were largely attributed to popular bus stops within the hexagon, rather than the number of bus stops.
Objective (2): Analysis of Local Indicators of Spatial Association (LISA)
Merging Passenger Trips with Geospatial data
Append the merged busstop_hxgn_grid frame onto the complete odbus data frame as follows.
<- left_join(busstop_hxgn_grid, odbus,
odbus_hxgn_grid by = c("BUS_STOP_N" = "ORIGIN_PT_CODE")) %>%
rename(ORIGIN_BS = BUS_STOP_N,
ORIGIN_GRID = grid_id) %>%
group_by(ORIGIN_GRID) %>%
reframe(
MAX_TRIPS_BS = ORIGIN_BS[which.max(sum(TOTAL_TRIPS))],
TOT_TRIPS = sum(TOTAL_TRIPS),
AVG_TRIPS = mean(TOTAL_TRIPS)
)
As a good practice, check for duplicate records and retain duplicate records as follows.
<- odbus_hxgn_grid %>%
duplicate4 group_by_all() %>%
filter(n()>1) %>%
ungroup()
<- unique(odbus_hxgn_grid) odbus_hxgn_grid
Merge resultant data with hexgon grid and filter as follows
<- left_join(hxgn_grid_sf,
odbus_hxgn_grid_sf
odbus_hxgn_grid,by = c("grid_id" = "ORIGIN_GRID"))%>%
select(MAX_TRIPS_BS, TOT_TRIPS, AVG_TRIPS, hxgn_grid)
= filter(odbus_hxgn_grid_sf, TOT_TRIPS > 0) odbus_hxgn_grid_sf
Deriving adaptive distance based-weights
derive an adaptive spatial weights by using the code chunk below.
<- odbus_hxgn_grid_sf %>%
wm_ad mutate(nb = st_knn(hxgn_grid,
k=8),
wt = st_weights(nb),
.before = 1)
! Polygon provided. Using point on surface.
Computing local Moran’s I
Compute Local Moran’s I of GDPPC at county level by using local_moran() of sfdep package as follows.
<- wm_ad %>%
lisa mutate(local_moran = local_moran(
nsim = 99),
TOT_TRIPS, nb, wt, .before = 1) %>%
unnest(local_moran)
Visualising local Moran’s I and p-value
For effective comparison, the Moran I and p-value maps will be plotted next to each other as shown below.
tmap_mode("plot")
tmap mode set to plotting
<- tm_shape(lisa) +
map1 tm_fill("ii") +
tm_borders(alpha = 0.5) +
tm_layout(main.title = "local Moran's I of Passenger Trips",
main.title.size = 0.8)
<- tm_shape(lisa) +
map2 tm_fill("p_ii_sim",
breaks = c(0, 0.001, 0.01, 0.05, 1),
labels = c("0.001", "0.01", "0.05", "Not sig")) +
tm_borders(alpha = 0.5) +
tm_layout(main.title = "p-value of local Moran's I",
main.title.size = 0.8)
tmap_arrange(map1, map2, ncol = 2)
Variable(s) "ii" contains positive and negative values, so midpoint is set to 0. Set midpoint = NA to show the full spectrum of the color palette.
From the above, it is noted that majority of the points had p-values that were more than 0.05, which meant that there were insufficient statistical evidence that the passenger trip origins were clusters or outliers relative it its closest 6 neighbours. However, there seems to be a noticeable cluster in the northwestern and southwestern region of Singapore, where the p-values are less than 0.05
The majority of points also had ii values that were close to 0, which meant that they low spatial correlation in passenger trip origins.
Visualising LISA map
The LISA map is plotted with 2 types of outliers (High-Low and Low-High), and two type of clusters (namely: (High-High and Low-Low), which combines local Moran’s I of geographical areas and their respective p-values. For this plot, the classification in mean is used as shown below.
<- lisa %>%
lisa_sig filter(p_ii_sim < 0.05)
tmap_mode("plot")
tmap mode set to plotting
tm_shape(lisa) +
tm_polygons() +
tm_borders(alpha = 0.5) +
tm_shape(lisa_sig) +
tm_fill("mean") +
tm_borders(alpha = 0.4)
Warning: One tm layer group has duplicated layer types, which are omitted. To
draw multiple layers of the same type, use multiple layer groups (i.e. specify
tm_shape prior to each of them).
Aligned with the earlier analysis, noticeable/sizeable low-low clusters can be observed in the north western and southwestern region of Singapore. Notwithstanding, there are also other low-low and high-high clusters sparsely distributed across the other regions, as well as rarer occurences of high-low and low-high outliers across Singapore.