pacman::p_load(spdep, tmap, sf, ClustGeo,
ggpubr, cluster, factoextra, NbClust,
heatmaply, corrplot, psych, tidyverse, GGally)Geographical Segmentation with Spatially Constrained Clustering
HANDS-ON EXERCISE 6
R ClustGeo SKATER Ward Minimum spanning tree
This exercise follows Geographical Segmentation with Spatially Constrained Clustering Techniques, the reading for the Geographic Segmentation block of the course.
1. Overview
Exercises 4 and 5 asked whether values were spatially clustered. This one asks a different question: given a set of multivariate measurements attached to areal units, how do I carve the study area into a small number of internally homogeneous regions?
That is segmentation rather than detection. The practical use is planning: a telecoms regulator, a market-entry team or a ministry allocating infrastructure does not want 55 townships, it wants five or six coherent zones it can write a policy for.
The complication is that ordinary cluster analysis knows nothing about geography. It will happily place two townships at opposite ends of Shan State in the same cluster because their radio ownership happens to match. A region you cannot draw on a map is not much use as a planning unit. Two families of method address this:
- SKATER, which prunes a minimum spanning tree built on the contiguity graph, so contiguity is enforced by construction;
- ClustGeo, which blends an attribute dissimilarity matrix with a geographic distance matrix through a mixing parameter
alpha, trading homogeneity for compactness continuously.
By the end I should be able to run all three (plain hierarchical, SKATER, ClustGeo) and say something defensible about what each one costs.
2. The Study Area and Data
| Dataset | Description | Format |
|---|---|---|
myanmar_township_boundaries |
Township boundaries for Myanmar, filtered to the three Shan states | ESRI Shapefile (polygon) |
Shan-ICT.csv |
Household ICT ownership from the 2014 Myanmar Population and Housing Census, township level | CSV |
Both come from the Myanmar Information Management Unit (MIMU). The filter keeps Shan (East), Shan (North) and Shan (South), which gives 55 townships.
Shan State is a reasonable choice for this problem. It is large, internally very uneven, and the ICT variables are genuinely multivariate: radio is a legacy technology with a different geography from mobile phones, so the clustering has something to work with rather than six proxies for one underlying wealth gradient.
3. Getting Started
4. Data Import and Preparation
4.1 Importing the township boundaries
shan_sf <- st_read(dsn = "data/geospatial",
layer = "myanmar_township_boundaries") %>%
filter(ST %in% c("Shan (East)", "Shan (North)", "Shan (South)")) %>%
select(c(2:7))Reading layer `myanmar_township_boundaries' from data source
`/Users/jedidiahasaf/Downloads/Projects/isss626-coursework/Hands-on_Ex/Hands-on_Ex06/data/geospatial'
using driver `ESRI Shapefile'
Simple feature collection with 330 features and 14 fields
Geometry type: MULTIPOLYGON
Dimension: XY
Bounding box: xmin: 92.17275 ymin: 9.671252 xmax: 101.1699 ymax: 28.54554
Geodetic CRS: WGS 84
glimpse(shan_sf)Rows: 55
Columns: 7
$ ST <chr> "Shan (North)", "Shan (South)", "Shan (South)", "Shan (South)~
$ ST_PCODE <chr> "MMR015", "MMR014", "MMR014", "MMR014", "MMR015", "MMR014", "~
$ DT <chr> "Mongmit", "Taunggyi", "Taunggyi", "Taunggyi", "Mongmit", "Ta~
$ DT_PCODE <chr> "MMR015D008", "MMR014D001", "MMR014D001", "MMR014D001", "MMR0~
$ TS <chr> "Mongmit", "Pindaya", "Ywangan", "Pinlaung", "Mabein", "Kalaw~
$ TS_PCODE <chr> "MMR015017", "MMR014006", "MMR014007", "MMR014009", "MMR01501~
$ geometry <MULTIPOLYGON [arc_degree]> MULTIPOLYGON (((96.96001 23..., MULTIPO~
55 townships, six attribute fields and a geometry column, in WGS 84.
4.2 Importing the ICT data
ict <- read_csv("data/aspatial/Shan-ICT.csv")
summary(ict) District Pcode District Name Township Pcode Township Name
Length:55 Length:55 Length:55 Length:55
Class :character Class :character Class :character Class :character
Mode :character Mode :character Mode :character Mode :character
Total households Radio Television Land line phone
Min. : 3318 Min. : 115 Min. : 728 Min. : 20.0
1st Qu.: 8711 1st Qu.: 1260 1st Qu.: 3744 1st Qu.: 266.5
Median :13685 Median : 2497 Median : 6117 Median : 695.0
Mean :18369 Mean : 4487 Mean :10183 Mean : 929.9
3rd Qu.:23471 3rd Qu.: 6192 3rd Qu.:13906 3rd Qu.:1082.5
Max. :82604 Max. :30176 Max. :62388 Max. :6736.0
Mobile phone Computer Internet at home
Min. : 150 Min. : 20.0 Min. : 8.0
1st Qu.: 2037 1st Qu.: 121.0 1st Qu.: 88.0
Median : 3559 Median : 244.0 Median : 316.0
Mean : 6470 Mean : 575.5 Mean : 760.2
3rd Qu.: 7177 3rd Qu.: 507.0 3rd Qu.: 630.5
Max. :48461 Max. :6705.0 Max. :9746.0
4.3 Deriving penetration rates
The raw counts are households owning each device. Using them directly would cluster townships by size, not by technology profile: a township with 80,000 households will have more of everything. Converting to a rate per 1,000 households removes that.
ict_derived <- ict %>%
mutate(`RADIO_PR` = `Radio`/`Total households`*1000) %>%
mutate(`TV_PR` = `Television`/`Total households`*1000) %>%
mutate(`LLPHONE_PR` = `Land line phone`/`Total households`*1000) %>%
mutate(`MPHONE_PR` = `Mobile phone`/`Total households`*1000) %>%
mutate(`COMPUTER_PR` = `Computer`/`Total households`*1000) %>%
mutate(`INTERNET_PR` = `Internet at home`/`Total households`*1000) %>%
rename(`DT_PCODE` =`District Pcode`, `DT`=`District Name`,
`TS_PCODE`=`Township Pcode`, `TS`=`Township Name`,
`TT_HOUSEHOLDS`=`Total households`,
`RADIO`=`Radio`, `TV`=`Television`,
`LLPHONE`=`Land line phone`, `MPHONE`=`Mobile phone`,
`COMPUTER`=`Computer`, `INTERNET`=`Internet at home`)
summary(ict_derived) DT_PCODE DT TS_PCODE TS
Length:55 Length:55 Length:55 Length:55
Class :character Class :character Class :character Class :character
Mode :character Mode :character Mode :character Mode :character
TT_HOUSEHOLDS RADIO TV LLPHONE
Min. : 3318 Min. : 115 Min. : 728 Min. : 20.0
1st Qu.: 8711 1st Qu.: 1260 1st Qu.: 3744 1st Qu.: 266.5
Median :13685 Median : 2497 Median : 6117 Median : 695.0
Mean :18369 Mean : 4487 Mean :10183 Mean : 929.9
3rd Qu.:23471 3rd Qu.: 6192 3rd Qu.:13906 3rd Qu.:1082.5
Max. :82604 Max. :30176 Max. :62388 Max. :6736.0
MPHONE COMPUTER INTERNET RADIO_PR
Min. : 150 Min. : 20.0 Min. : 8.0 Min. : 21.05
1st Qu.: 2037 1st Qu.: 121.0 1st Qu.: 88.0 1st Qu.:138.95
Median : 3559 Median : 244.0 Median : 316.0 Median :210.95
Mean : 6470 Mean : 575.5 Mean : 760.2 Mean :215.68
3rd Qu.: 7177 3rd Qu.: 507.0 3rd Qu.: 630.5 3rd Qu.:268.07
Max. :48461 Max. :6705.0 Max. :9746.0 Max. :484.52
TV_PR LLPHONE_PR MPHONE_PR COMPUTER_PR
Min. :116.0 Min. : 2.78 Min. : 36.42 Min. : 3.278
1st Qu.:450.2 1st Qu.: 22.84 1st Qu.:190.14 1st Qu.:11.832
Median :517.2 Median : 37.59 Median :305.27 Median :18.970
Mean :509.5 Mean : 51.09 Mean :314.05 Mean :24.393
3rd Qu.:606.4 3rd Qu.: 69.72 3rd Qu.:428.43 3rd Qu.:29.897
Max. :842.5 Max. :181.49 Max. :735.43 Max. :92.402
INTERNET_PR
Min. : 1.041
1st Qu.: 8.617
Median : 22.829
Mean : 30.644
3rd Qu.: 41.281
Max. :117.985
5. Exploratory Data Analysis
5.1 Counts versus rates
ggarrange(
ggplot(ict_derived, aes(x = RADIO)) +
geom_histogram(bins = 20, color = "black", fill = "light blue") +
ggtitle("Households owning a radio (count)"),
ggplot(ict_derived, aes(x = RADIO_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue") +
ggtitle("Radio per 1,000 households"),
ncol = 2)
The count distribution is strongly right-skewed, which is a size effect: a handful of large townships sit far out in the tail. The rate is much closer to symmetric. This is the whole justification for section 4.3 in one picture.
5.2 All six penetration rates
radio <- ggplot(ict_derived, aes(x = RADIO_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue")
tv <- ggplot(ict_derived, aes(x = TV_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue")
llphone <- ggplot(ict_derived, aes(x = LLPHONE_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue")
mphone <- ggplot(ict_derived, aes(x = MPHONE_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue")
computer <- ggplot(ict_derived, aes(x = COMPUTER_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue")
internet <- ggplot(ict_derived, aes(x = INTERNET_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue")
ggarrange(radio, tv, llphone, mphone, computer, internet,
ncol = 3, nrow = 2)
Radio and TV are broad and roughly symmetric. Landline, computer and internet are all heavily right-skewed with a floor near zero, which is what low-penetration technology looks like: most townships have almost none, a few urban ones have some.
5.3 Joining geospatial and aspatial data
shan_sf <- left_join(shan_sf, ict_derived, by = c("TS_PCODE" = "TS_PCODE"))sum(!is.na(shan_sf$TT_HOUSEHOLDS))[1] 55
All 55 townships matched on TS_PCODE. Worth checking rather than assuming, because a left join silently produces NA rather than failing.
5.4 Choropleths: why the rate matters
TT_HOUSEHOLDS.map <- tm_shape(shan_sf) +
tm_polygons(fill = "TT_HOUSEHOLDS",
fill.scale = tm_scale_intervals(n = 5, style = "jenks"),
fill.legend = tm_legend(title = "Total households")) +
tm_borders(col_alpha = 0.5)
RADIO.map <- tm_shape(shan_sf) +
tm_polygons(fill = "RADIO",
fill.scale = tm_scale_intervals(n = 5, style = "jenks"),
fill.legend = tm_legend(title = "Radio (count)")) +
tm_borders(col_alpha = 0.5)
tmap_arrange(TT_HOUSEHOLDS.map, RADIO.map, asp = NA, ncol = 2)
The two maps are near-identical. The count map is essentially a population map.
RADIO_PR.map <- tm_shape(shan_sf) +
tm_polygons(fill = "RADIO_PR",
fill.scale = tm_scale_intervals(n = 5, style = "jenks"),
fill.legend = tm_legend(title = "Radio per 1,000")) +
tm_borders(col_alpha = 0.5)
tmap_arrange(TT_HOUSEHOLDS.map, RADIO_PR.map, asp = NA, ncol = 2)
Once normalised the pattern inverts in places: some of the least populous townships have the highest radio penetration. That is a real signal, and it was completely hidden in the count map.
6. Correlation Analysis
Cluster analysis on strongly collinear variables effectively double-counts whatever they share.
cluster_vars.cor = cor(ict_derived[,12:17])
corrplot.mixed(cluster_vars.cor,
lower = "ellipse",
upper = "number",
tl.pos = "lt",
diag = "l",
tl.col = "black")
COMPUTER_PR and INTERNET_PR correlate at 0.87, far above any other pair (MPHONE_PR–COMPUTER_PR is 0.74, TV_PR–MPHONE_PR is 0.72). In 2014 Myanmar a home internet connection almost always implied a computer, so the two are close to the same measurement. I drop INTERNET_PR and keep COMPUTER_PR, following the handout.
Worth noting what is not collinear: RADIO_PR is essentially uncorrelated with everything else (its largest correlation with any other variable is 0.15). Radio is not a proxy for wealth here, it is its own axis, and it will do real work in the clustering.
7. Hierarchical Cluster Analysis
7.1 Extracting the clustering variables
cluster_vars <- shan_sf %>%
st_set_geometry(NULL) %>%
select("TS.x", "RADIO_PR", "TV_PR", "LLPHONE_PR", "MPHONE_PR", "COMPUTER_PR")
row.names(cluster_vars) <- cluster_vars$"TS.x"
shan_ict <- select(cluster_vars, c(2:6))
head(shan_ict, 10) RADIO_PR TV_PR LLPHONE_PR MPHONE_PR COMPUTER_PR
Mongmit 286.1852 554.1313 35.30618 260.6944 12.15939
Pindaya 417.4647 505.1300 19.83584 162.3917 12.88190
Ywangan 484.5215 260.5734 11.93591 120.2856 4.41465
Pinlaung 231.6499 541.7189 28.54454 249.4903 13.76255
Mabein 449.4903 708.6423 72.75255 392.6089 16.45042
Kalaw 280.7624 611.6204 42.06478 408.7951 29.63160
Pekon 318.6118 535.8494 39.83270 214.8476 18.97032
Lawksawk 387.1017 630.0035 31.51366 320.5686 21.76677
Nawnghkio 349.3359 547.9456 38.44960 323.0201 15.76465
Kyaukme 210.9548 601.1773 39.58267 372.4930 30.94709
7.2 Standardisation
shan_ict.std <- normalize(shan_ict)
summary(shan_ict.std) RADIO_PR TV_PR LLPHONE_PR MPHONE_PR
Min. :0.0000 Min. :0.0000 Min. :0.0000 Min. :0.0000
1st Qu.:0.2544 1st Qu.:0.4600 1st Qu.:0.1123 1st Qu.:0.2199
Median :0.4097 Median :0.5523 Median :0.1948 Median :0.3846
Mean :0.4199 Mean :0.5416 Mean :0.2703 Mean :0.3972
3rd Qu.:0.5330 3rd Qu.:0.6750 3rd Qu.:0.3746 3rd Qu.:0.5608
Max. :1.0000 Max. :1.0000 Max. :1.0000 Max. :1.0000
COMPUTER_PR
Min. :0.00000
1st Qu.:0.09598
Median :0.17607
Mean :0.23692
3rd Qu.:0.29868
Max. :1.00000
shan_ict.z <- scale(shan_ict)
describe(shan_ict.z) vars n mean sd median trimmed mad min max range skew kurtosis
RADIO_PR 1 55 0 1 -0.04 -0.06 0.94 -1.85 2.55 4.40 0.48 -0.27
TV_PR 2 55 0 1 0.05 0.04 0.78 -2.47 2.09 4.56 -0.38 -0.23
LLPHONE_PR 3 55 0 1 -0.33 -0.15 0.68 -1.19 3.20 4.39 1.37 1.49
MPHONE_PR 4 55 0 1 -0.05 -0.06 1.01 -1.58 2.40 3.98 0.48 -0.34
COMPUTER_PR 5 55 0 1 -0.26 -0.18 0.64 -1.03 3.31 4.34 1.80 2.96
se
RADIO_PR 0.13
TV_PR 0.13
LLPHONE_PR 0.13
MPHONE_PR 0.13
COMPUTER_PR 0.13
r <- ggplot(ict_derived, aes(x = RADIO_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue") +
ggtitle("Raw")
s <- ggplot(as.data.frame(shan_ict.std), aes(x = RADIO_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue") +
ggtitle("Min-Max")
z <- ggplot(as.data.frame(shan_ict.z), aes(x = RADIO_PR)) +
geom_histogram(bins = 20, color = "black", fill = "light blue") +
ggtitle("Z-score")
ggarrange(r, s, z, ncol = 3, nrow = 1)
The shape is identical in all three. Standardisation rescales an axis, it does not reshape a distribution. What it changes is how much each variable counts when distances between townships are computed, and I come back to that in section 11.
7.3 Proximity matrix
proxmat <- dist(shan_ict, method = 'euclidean')7.4 Choosing the linkage
agnes() reports an agglomerative coefficient, where values near 1 indicate a stronger clustering structure.
m <- c("average", "single", "complete", "ward")
names(m) <- c("average", "single", "complete", "ward")
ac <- function(x) { agnes(shan_ict, method = x)$ac }
map_dbl(m, ac) average single complete ward
0.8131144 0.6628705 0.8950702 0.9427730
Ward wins clearly at 0.943, against 0.895 for complete and 0.663 for single linkage. Single linkage performing worst is expected: it chains, producing one large straggly cluster and several singletons. Ward is used from here.
7.5 How many clusters?
set.seed(12345)
gap_stat <- clusGap(shan_ict, FUN = hcut, nstart = 25, K.max = 10, B = 50)
print(gap_stat, method = "firstmax")Clustering Gap statistic ["clusGap"] from call:
clusGap(x = shan_ict, FUNcluster = hcut, K.max = 10, B = 50, nstart = 25)
B=50 simulated reference sets, k = 1..10; spaceH0="scaledPCA"
--> Number of clusters (method 'firstmax'): 1
logW E.logW gap SE.sim
[1,] 8.407129 8.680794 0.2736651 0.04460994
[2,] 8.130029 8.350712 0.2206824 0.03880130
[3,] 7.992265 8.202550 0.2102844 0.03362652
[4,] 7.862224 8.080655 0.2184311 0.03784781
[5,] 7.756461 7.978022 0.2215615 0.03897071
[6,] 7.665594 7.887777 0.2221833 0.03973087
[7,] 7.590919 7.806333 0.2154145 0.04054939
[8,] 7.526680 7.731619 0.2049390 0.04198644
[9,] 7.458024 7.660795 0.2027705 0.04421874
[10,] 7.377412 7.593858 0.2164465 0.04540947
fviz_gap_stat(gap_stat)
firstmax returns k = 1. The honest reading is that the gap statistic finds no strong evidence of cluster structure in these five variables at all: the gap never rises above its k=1 value of 0.274, and from k=2 to k=10 it sits in a narrow band between 0.203 and 0.222.
That is not a number I can report as “the answer is 6”. What I can say is that given a decision to partition, k = 6 is the local maximum within the usable range and is what the handout adopts, so I use 6 for comparability. But the segmentation here rests on a planning decision to have six regions, not on a statistical finding that six exist.
7.6 The dendrogram
plot(hclust_ward <- hclust(proxmat, method = 'ward.D'), cex = 0.6)
rect.hclust(hclust_ward, k = 6, border = 2:5)
7.7 Visually-driven analysis with a cluster heatmap
shan_ict_mat <- data.matrix(shan_ict)
heatmaply(normalize(shan_ict_mat),
Colv = NA,
dist_method = "euclidean",
hclust_method = "ward.D",
seriate = "OLO",
colors = Blues,
k_row = 6,
margins = c(NA, 200, 60, NA),
fontsize_row = 4,
fontsize_col = 5,
main = "Geographic Segmentation of Shan State by ICT indicators",
xlab = "ICT Indicators",
ylab = "Townships of Shan State")7.8 Mapping the clusters
groups <- as.factor(cutree(hclust_ward, k = 6))
shan_sf_cluster <- cbind(shan_sf, as.matrix(groups)) %>%
rename(`CLUSTER` = `as.matrix.groups.`)
table(shan_sf_cluster$CLUSTER)
1 2 3 4 5 6
20 4 13 6 7 5
qtm(shan_sf_cluster, "CLUSTER")
The clusters are badly fragmented. Cluster 1 alone appears in pieces scattered across the whole state. As a typology this is fine, but as a set of planning regions it is unusable, which is exactly the motivation for the next two sections.
8. Spatially Constrained Clustering: SKATER
8.1 Converting to a spatial object
shan_sp <- as_Spatial(shan_sf)8.2 Neighbour list
shan.nb <- poly2nb(shan_sp)
summary(shan.nb)Neighbour list object:
Number of regions: 55
Number of nonzero links: 264
Percentage nonzero weights: 8.727273
Average number of links: 4.8
Link number distribution:
2 3 4 5 6 7 8 9
5 9 7 21 4 3 5 1
5 least connected regions:
3 5 7 9 47 with 2 links
1 most connected region:
8 with 9 links
264 non-zero links, 4.8 neighbours on average, nothing isolated. A township with no neighbours would break the minimum spanning tree, so this check matters.
coords <- st_coordinates(st_centroid(st_geometry(shan_sf)))
plot(st_geometry(shan_sf), border = grey(.5))
plot(shan.nb, coords, col = "blue", add = TRUE)
8.3 Edge costs and the minimum spanning tree
The cost of each edge is the attribute dissimilarity between the two townships it joins. Note the weights style is B, not row-standardised: these are distances, and scaling them per row would destroy their meaning.
lcosts <- nbcosts(shan.nb, shan_ict)
shan.w <- nb2listw(shan.nb, lcosts, style = "B")
summary(shan.w)Characteristics of weights list object:
Neighbour list object:
Number of regions: 55
Number of nonzero links: 264
Percentage nonzero weights: 8.727273
Average number of links: 4.8
Link number distribution:
2 3 4 5 6 7 8 9
5 9 7 21 4 3 5 1
5 least connected regions:
3 5 7 9 47 with 2 links
1 most connected region:
8 with 9 links
Weights style: B
Weights constants summary:
n nn S0 S1 S2
B 55 3025 76267.65 58260785 522016004
shan.mst <- mstree(shan.w)
dim(shan.mst)[1] 54 3
54 edges for 55 nodes, as a spanning tree must have.
plot(st_geometry(shan_sf), border = gray(.5))
plot.mst(shan.mst, coords, col = "blue",
cex.lab = 0.7, cex.circles = 0.005, add = TRUE)
8.4 Pruning the tree
clust6 <- spdep::skater(edges = shan.mst[,1:2],
data = shan_ict,
method = "euclidean",
ncuts = 5)
table(clust6$groups)
1 2 3 4 5 6
22 18 11 2 1 1
The six groups are 22, 18, 11, 2, 1, 1. Two of the six “regions” are single townships and a third has two. Effectively this is a three-region solution with three outliers shaved off.
That is a structural property of the method, not bad luck. SKATER can only cut edges of the spanning tree, so an extreme township that sits on a leaf gets isolated by the cheapest available cut long before the algorithm will split a large interior block. If the goal is six comparably sized planning regions, SKATER at ncuts = 5 does not deliver them.
clust6$ssto[1] 12612.52
clust6$ssw[1] 12612.515 10977.044 9961.641 9540.422 9122.654 8735.162
Total sum of squares is 12,613 and each of the five cuts reduces the within-cluster sum of squares, ending at 8,735. The cuts are doing real work, just unevenly.
plot(st_geometry(shan_sf), border = gray(.5))
plot(clust6, coords, cex.lab = .7,
groups.colors = c("red", "green", "blue", "brown", "pink"),
cex.circles = 0.005, add = TRUE)
8.5 Comparing unconstrained and constrained
groups_mat <- as.matrix(clust6$groups)
shan_sf_spatialcluster <- cbind(shan_sf_cluster, as.factor(groups_mat)) %>%
rename(`SP_CLUSTER` = `as.factor.groups_mat.`)
tmap_arrange(qtm(shan_sf_cluster, "CLUSTER") + tm_borders(col_alpha = 0.5),
qtm(shan_sf_spatialcluster, "SP_CLUSTER") + tm_borders(col_alpha = 0.5),
asp = NA, ncol = 2)
The right-hand map is made of solid contiguous blocks. That is the entire point of SKATER, and it is visually obvious.
9. Spatially Constrained Clustering: ClustGeo
9.1 Ward-like clustering without a spatial constraint
nongeo_cluster <- hclustgeo(proxmat)
plot(nongeo_cluster, cex = 0.5)
rect.hclust(nongeo_cluster, k = 6, border = 2:5)
hclustgeo() with no constraint is not the same as the hclust() above
The handout presents hclustgeo(proxmat) as doing “a typical Ward-like hierarchical clustering just like hclust()”. It is Ward-like, but it is not the same Ward.
My hclust() call in section 7.6 used method = "ward.D" and gave cluster sizes 20/4/13/6/7/5. hclustgeo(proxmat) gives 16/8/13/6/7/5. I checked where the difference comes from, and hclustgeo(proxmat) reproduces hclust(proxmat, "ward.D2") exactly, township for township, not ward.D.
The distinction is that ward.D2 squares the dissimilarities before applying the Ward update and ward.D does not, so only ward.D2 implements Ward’s actual criterion on a non-squared input distance matrix. Twelve townships move between clusters 1 and 2 as a result. It is a small difference here but it means the section 7 dendrogram and the section 9 dendrogram are not two views of one clustering.
groups <- as.factor(cutree(nongeo_cluster, k = 6))
shan_sf_ngeo_cluster <- cbind(shan_sf, as.matrix(groups)) %>%
rename(`CLUSTER` = `as.matrix.groups.`)qtm(shan_sf_ngeo_cluster, "CLUSTER")
9.2 Choosing the mixing parameter
D0 holds attribute dissimilarity, D1 holds geographic distance, and alpha sets how much weight goes to each.
dist <- st_distance(shan_sf, shan_sf)
distmat <- as.dist(dist)cr <- choicealpha(proxmat, distmat, range.alpha = seq(0, 1, 0.1), K = 6, graph = TRUE)

round(cr$Qnorm, 3) Q0norm Q1norm
alpha=0 1.000 0.139
alpha=0.1 0.978 0.301
alpha=0.2 0.855 0.665
alpha=0.3 0.825 0.735
alpha=0.4 0.697 0.882
alpha=0.5 0.652 0.928
alpha=0.6 0.652 0.928
alpha=0.7 0.635 0.934
alpha=0.8 0.543 0.999
alpha=0.9 0.496 0.975
alpha=1 0.252 1.000
Q0norm is the proportion of attribute homogeneity retained, Q1norm the geographic homogeneity. Moving from alpha = 0 to alpha = 0.2 costs about 14% of attribute homogeneity (1.000 → 0.855) and buys a jump in geographic homogeneity from 0.139 to 0.665. That is a very favourable exchange and it is why 0.2 is the conventional choice here.
Pushing on to alpha = 0.3 costs a further 3 points of attribute homogeneity for 7 points of geographic, still positive but clearly past the knee. I follow the handout and use 0.2.
9.3 The spatially constrained solution
clustG <- hclustgeo(proxmat, distmat, alpha = 0.2)
groups <- as.factor(cutree(clustG, k = 6))
shan_sf_Gcluster <- cbind(shan_sf, as.matrix(groups)) %>%
rename(`CLUSTER` = `as.matrix.groups.`)
table(shan_sf_Gcluster$CLUSTER)
1 2 3 4 5 6
10 9 7 9 12 8
qtm(shan_sf_Gcluster, "CLUSTER")
Much more balanced than SKATER (12/10/9/9/8/7, no singletons) and visibly more spatially coherent than the unconstrained solution, though not perfectly contiguous. Section 11 measures that.
10. Visual Interpretation of Clusters
10.1 One variable at a time
ggplot(data = shan_sf_ngeo_cluster, aes(x = CLUSTER, y = RADIO_PR)) +
geom_boxplot()
Cluster 3 has the highest median radio penetration and cluster 6 the lowest, with cluster 5 also low. A single variable only ever tells part of the story though.
10.2 Parallel coordinate plot
pc_cols <- match(c("RADIO_PR", "TV_PR", "LLPHONE_PR", "MPHONE_PR", "COMPUTER_PR"),
names(shan_sf_ngeo_cluster))
ggparcoord(data = shan_sf_ngeo_cluster,
columns = pc_cols,
scale = "globalminmax",
alphaLines = 0.2,
boxplot = TRUE,
title = "Parallel coordinates of ICT variables by cluster") +
facet_grid(~ CLUSTER) +
theme(axis.text.x = element_text(angle = 30, hjust = 1))
10.3 Cluster means
shan_sf_ngeo_cluster %>%
st_set_geometry(NULL) %>%
group_by(CLUSTER) %>%
summarise(n = n(),
mean_RADIO_PR = round(mean(RADIO_PR), 1),
mean_TV_PR = round(mean(TV_PR), 1),
mean_LLPHONE_PR = round(mean(LLPHONE_PR), 1),
mean_MPHONE_PR = round(mean(MPHONE_PR), 1),
mean_COMPUTER_PR = round(mean(COMPUTER_PR), 1))# A tibble: 6 x 7
CLUSTER n mean_RADIO_PR mean_TV_PR mean_LLPHONE_PR mean_MPHONE_PR
<chr> <int> <dbl> <dbl> <dbl> <dbl>
1 1 16 221. 521. 44.2 246.
2 2 8 237. 402. 23.9 134.
3 3 13 300. 611. 52.2 392.
4 4 6 196. 744. 99 651.
5 5 7 124 224. 38 132
6 6 5 98.6 499. 74.5 468.
# i 1 more variable: mean_COMPUTER_PR <dbl>
The six profiles are interpretable:
- Cluster 4 (n = 6) is the connected one: highest on TV (744), landline (99), mobile
- and computer (65.5) by a wide margin. These are the urban centres.
- Cluster 5 (n = 7) is the opposite on everything modern (TV 224, mobile 132, computer 6.7) but mid-range on radio. Low-penetration remote townships.
- Cluster 6 (n = 5) is the interesting one: lowest radio of all (98.6) yet high mobile (468). These townships appear to have skipped radio rather than lagged on it.
- Cluster 3 (n = 13) is the high-radio group (300) with solid mobile (392).
- Clusters 1 and 2 are the middle, separated mainly by mobile and TV.
Clusters 5 and 6 have almost the same low-ish profile on landline and computer, yet they are separated decisively by radio: 124 versus 98.6 on radio but 132 versus 468 on mobile. Without RADIO_PR in the variable set these two groups would likely collapse together. That is consistent with the correlation matrix in section 6, where radio was the one variable independent of the rest.
11. My Own Exploration: What Does the Spatial Constraint Actually Cost?
Every method here claims a different balance between two things I can measure directly:
- attribute homogeneity, as the total within-cluster sum of squares on standardised variables (lower is better);
- spatial coherence, as the number of contiguous pieces the six clusters break into (6 is perfect, higher is worse).
The handout asserts the trade-off exists. I wanted the numbers.
11.1 Counting contiguous pieces
pieces <- function(lab, nb) {
total <- 0
for (k in unique(lab)) {
idx <- which(lab == k); seen <- integer(0); comp <- 0
for (i in idx) {
if (i %in% seen) next
comp <- comp + 1; stack <- i
while (length(stack)) {
v <- stack[1]; stack <- stack[-1]
if (v %in% seen) next
seen <- c(seen, v)
nbrs <- nb[[v]]; nbrs <- nbrs[nbrs != 0]
stack <- c(stack, intersect(nbrs[!(nbrs %in% seen)], idx))
}
}
total <- total + comp
}
total
}
wss <- function(lab) {
x <- scale(shan_ict)
sum(sapply(unique(lab), function(k) {
s <- x[lab == k, , drop = FALSE]
if (nrow(s) < 2) return(0)
sum(scale(s, scale = FALSE)^2)
}))
}11.2 The trade-off, measured
cg3 <- cutree(hclustgeo(proxmat, distmat, alpha = 0.3), 6)
solutions <- list(
`hclust (ward.D)` = cutree(hclust_ward, 6),
`hclustgeo (a = 0)` = cutree(nongeo_cluster, 6),
`ClustGeo a = 0.2` = cutree(clustG, 6),
`ClustGeo a = 0.3` = cg3,
`SKATER` = clust6$groups
)
tibble(
method = names(solutions),
pieces = sapply(solutions, pieces, nb = shan.nb),
within_SS = round(sapply(solutions, wss), 1),
sizes = sapply(solutions, function(l)
paste(sort(as.vector(table(l)), decreasing = TRUE), collapse = "/"))
)# A tibble: 5 x 4
method pieces within_SS sizes
<chr> <dbl> <dbl> <chr>
1 hclust (ward.D) 24 107. 20/13/7/6/5/4
2 hclustgeo (a = 0) 28 110. 16/13/8/7/6/5
3 ClustGeo a = 0.2 18 130. 12/10/9/9/8/7
4 ClustGeo a = 0.3 17 134. 21/8/8/7/6/5
5 SKATER 6 151. 22/18/11/2/1/1
Three things came out of this that I did not get from the maps.
The unconstrained solutions are extremely fragmented. Six clusters break into 24 and 28 separate patches. That is roughly half of the 55 townships sitting in a patch of their own or nearly so. “Fragmented” undersells it.
SKATER’s contiguity is absolute and the price is 42%. It returns exactly 6 pieces for 6 clusters, as guaranteed by construction. Within-cluster sum of squares rises from 106.6 to 151.1, which is 42% worse attribute homogeneity. That is the real cost of mappable regions on this data, and it is not small.
ClustGeo at alpha = 0.2 does not deliver contiguity. This is the finding that surprised me. It still breaks into 18 pieces, only a third of the way from 24 to 6, while already giving up 22% of homogeneity. Raising alpha to 0.3 barely helps (17 pieces). ClustGeo pulls clusters toward geographic compactness through a distance penalty, but nothing in the algorithm forbids a cluster from being in two places at once. SKATER’s graph-based formulation is categorically different: contiguity is a constraint, not a preference.
So the two methods are not interchangeable alternatives. If the deliverable must be drawable as contiguous regions, only SKATER guarantees it. ClustGeo buys balance (no singletons, sizes 12/10/9/9/8/7 against SKATER’s 22/18/11/2/1/1) but not contiguity.
11.3 A second question: the handout standardises, then clusters on raw rates
Sections 7.2 computed both a min-max and a z-score standardisation, and then the proximity matrix in 7.3 was built from shan_ict, the unstandardised rates. All five variables are per-1,000 rates so they share a unit, which is presumably the reasoning. But sharing a unit is not the same as sharing a scale.
v <- apply(shan_ict, 2, var)
tibble(variable = names(v),
sd = round(sqrt(v), 1),
pct_of_total_variance = round(100 * v / sum(v), 1))# A tibble: 5 x 3
variable sd pct_of_total_variance
<chr> <dbl> <dbl>
1 RADIO_PR 105. 16
2 TV_PR 159. 36.6
3 LLPHONE_PR 40.7 2.4
4 MPHONE_PR 176. 44.4
5 COMPUTER_PR 20.5 0.6
MPHONE_PR and TV_PR together carry 81% of the total variance. COMPUTER_PR contributes 0.6% and LLPHONE_PR 2.4%. Euclidean distance on these raw rates is therefore, to a good approximation, a distance on mobile phones and television only.
raw_k6 <- cutree(hclust(dist(shan_ict), "ward.D"), 6)
std_k6 <- cutree(hclust(dist(scale(shan_ict)), "ward.D"), 6)
adj_rand <- function(a, b) {
t <- table(a, b); n <- sum(t)
s <- sum(choose(t, 2))
sa <- sum(choose(rowSums(t), 2)); sb <- sum(choose(colSums(t), 2))
e <- sa * sb / choose(n, 2)
(s - e) / ((sa + sb) / 2 - e)
}
tibble(raw_sizes = paste(table(raw_k6), collapse = "/"),
z_sizes = paste(table(std_k6), collapse = "/"),
adjusted_rand = round(adj_rand(raw_k6, std_k6), 3))# A tibble: 1 x 3
raw_sizes z_sizes adjusted_rand
<chr> <chr> <dbl>
1 20/4/13/6/7/5 11/12/9/8/9/6 0.3
The adjusted Rand index between the raw-rate and z-scored partitions is 0.30. For reference, 1 would be identical and 0 would be the agreement expected by chance. 0.30 is weak agreement: these are largely different segmentations of Shan State, from the same variables and the same algorithm.
The z-scored version also produces much more even clusters (11/12/9/8/9/6 against 20/4/13/6/7/5), which is what I would expect once COMPUTER_PR is no longer contributing 0.6% of the distance.
I am not claiming the handout is wrong to use raw rates: if the analyst genuinely believes a 100-per-1,000 difference in mobile ownership matters more than a 100-per-1,000 difference in computer ownership, raw rates encode that belief correctly. The point is that this is a decision, it is unstated, and it moves the result a long way. Reporting the six clusters without mentioning it would make a choice look like a finding.
12. What I Take From This Exercise
Segmentation is not detection. Exercise 5 tested a hypothesis and returned a p-value. This exercise returns a partition, and there is no p-value for a partition. The gap statistic in section 7.5 pointed at k = 1, which in plain terms means these five variables do not contain strong natural groupings. Six regions is a planning decision made on top of a weak statistical signal, and that should be stated out loud rather than buried.
Contiguity costs 42% here, and I can now quantify it rather than assert it. The measurement in section 11.2 is the thing I will carry forward. Before this exercise I would have described the spatial constraint as a mild regularisation. On Shan State it raises within-cluster variance by two-fifths. Whether that is acceptable depends entirely on whether the output has to be drawn on a map and administered, which is a question about the client, not about the data.
ClustGeo and SKATER solve different problems. This is the correction I most needed. They are presented side by side as two spatially constrained methods, which made me assume they were two routes to the same place. They are not: SKATER enforces contiguity and distorts cluster sizes to do it; ClustGeo encourages compactness and preserves balance but left 18 disconnected pieces at the recommended alpha. Picking between them is picking which guarantee matters.
Two silent decisions moved the result more than any modelling choice. Dropping INTERNET_PR for collinearity was explicit and defensible. Clustering on unstandardised rates was neither, and it changed the partition to an adjusted Rand of 0.30 against the standardised alternative. The ward.D versus ward.D2 discrepancy in section 9.1 is smaller but the same species of problem: a default that quietly differs between two sections of the same analysis.
Connecting forward. The next exercise moves to geographically weighted regression, where a bandwidth plays a role structurally similar to alpha here, deciding how much geography constrains the estimate. The habit worth keeping from this exercise is to report what the constraint cost, not just that it was applied.