Data. Provincial summary tables of the China Migrants Dynamic Survey (CMDS), Basic Characteristics of the Floating Population by Province in China, National Earth System Science Data Center (http://www.geodata.cn). The raw tables are not redistributed here. To run the code, download the yearly packages for 2011–2018 and place them in a folder named data/ next to this file.

Sample. 31 provinces × 8 years = 248 province-years. The 2014 sex ratio is set to missing (see Section 2), so analyses that use the sex ratio cover 217 province-years.

library(readxl)
library(dplyr)
library(ggplot2)
library(ggrepel)
library(cluster)
library(rpart)

data_folder <- "data"
if (!length(list.files(data_folder, pattern = "\\.xlsx?$", recursive = TRUE)))
  stop("No data found: put the 2011-2018 data folders in a folder named 'data' next to this file.")
analysis_years <- c(2011, 2012, 2013, 2015, 2016, 2017, 2018)   # 2014 has its own reader

1. Data

1.1 Helpers

clean_prov <- function(x) {
  x <- as.character(x)
  x <- gsub("内蒙古自治区", "内蒙古", x)
  x <- gsub("(壮族|回族|维吾尔|藏族)?自治区$|省$|市$|特别行政区$", "", x)
  trimws(x)
}

file_for <- function(keyword, year) {
  fs <- list.files(data_folder, pattern = keyword, full.names = TRUE, recursive = TRUE)
  fs <- fs[grepl("\\.xls$|\\.xlsx$", fs)]
  fs <- fs[grepl(paste0(year, "年"), fs)]
  if (length(fs) == 0) NA else fs[1]
}

keep_prov <- function(out) out[nchar(out$province) >= 2 & nchar(out$province) <= 4, ]

1.2 Readers

# Marital status. 2011 has one "currently married" column; the
# "cohabiting" column (from 2016) is not part of the total.
read_marriage <- function(file_path, year) {
  raw <- as.data.frame(read_excel(file_path, col_names = FALSE))
  d <- raw[6:nrow(raw), ]
  prov  <- d[[9]]
  never <- as.numeric(d[[12]])
  first <- as.numeric(d[[14]])
  if (year == 2011) {
    rem <- 0
    div <- as.numeric(d[[16]])
    wid <- as.numeric(d[[18]])
  } else {
    rem <- as.numeric(d[[16]])
    div <- as.numeric(d[[18]])
    wid <- as.numeric(d[[20]])
  }
  out <- data.frame(year = year, province = clean_prov(prov),
                    never = never, first = first, rem = rem, div = div, wid = wid)
  out <- out[!is.na(out$never) & !is.na(out$first), ]
  out <- keep_prov(out)
  out$n_marital  <- out$never + out$first + out$rem + out$div + out$wid
  out$never_rate <- round(out$never / out$n_marital * 100, 2)
  out$first_rate <- round(out$first / out$n_marital * 100, 2)
  out[, c("year", "province", "never_rate", "first_rate", "n_marital")]
}

# Sex ratio (males per 100 females)
read_gender <- function(file_path, year) {
  raw <- as.data.frame(read_excel(file_path, col_names = FALSE))
  d <- raw[6:nrow(raw), ]
  male   <- as.numeric(d[[12]])
  female <- as.numeric(d[[14]])
  out <- data.frame(year = year, province = clean_prov(d[[9]]),
                    sex_ratio = round(male / female * 100, 1),
                    n_gender = male + female)
  out <- out[!is.na(out$sex_ratio), ]
  keep_prov(out)
}

# Youth share = ages 15-29 / ages 15-59
read_age <- function(file_path, year) {
  raw <- as.data.frame(read_excel(file_path, col_names = FALSE))
  header <- as.character(unlist(raw[2, ]))
  col_of <- function(seg) {
    h <- which(grepl(seg, header) & grepl("人数", header))
    if (length(h)) h[1] else NA
  }
  segs <- c("15-19", "20-24", "25-29", "30-34", "35-39", "40-44", "45-49", "50-54", "55-59")
  idxs <- sapply(segs, col_of)
  if (any(is.na(idxs))) return(NULL)
  d <- raw[6:nrow(raw), ]
  counts <- sapply(idxs, function(c) as.numeric(d[[c]]))
  out <- data.frame(year = year, province = clean_prov(d[[9]]),
                    young_share = round(rowSums(counts[, 1:3]) / rowSums(counts) * 100, 1))
  out <- out[!is.na(out$young_share), ]
  keep_prov(out)
}

# Tertiary-education share (junior college or above)
read_edu <- function(file_path, year) {
  raw <- as.data.frame(read_excel(file_path, col_names = FALSE))
  header <- as.character(unlist(raw[2, ]))
  find_col <- function(label) {
    h <- which(grepl(label, header) & grepl("人数", header))
    if (!length(h)) h <- which(grepl(label, header) & !grepl("比重", header))
    if (length(h)) h[1] else NA
  }
  d <- raw[6:nrow(raw), ]
  gc <- function(lab) {
    c <- find_col(lab)
    if (is.na(c)) rep(0, nrow(d)) else as.numeric(d[[c]])
  }
  higher <- gc("大学专科") + gc("大学本科") + gc("研究生")
  hs <- gc("高中/中专")
  if (all(hs == 0, na.rm = TRUE)) hs <- gc("高中") + gc("中专")
  base <- gc("未上过学") + gc("小学") + gc("初中") + higher
  out <- data.frame(year = year, province = clean_prov(d[[9]]),
                    higher_edu = round(higher / (base + hs) * 100, 1))
  out <- out[!is.na(out$higher_edu), ]
  keep_prov(out)
}

# Agricultural-hukou share, computed from counts (the published share
# columns are defined differently across years)
read_hukou <- function(file_path, year) {
  raw <- as.data.frame(read_excel(file_path, col_names = FALSE))
  header <- gsub("户口性质_", "", as.character(unlist(raw[2, ])))
  d <- raw[6:nrow(raw), ]
  cs <- function(name) {
    cc <- which(header == name)
    if (!length(cc)) rep(0, nrow(d)) else as.numeric(d[[cc[1]]])
  }
  agri  <- cs("农业人数") + cs("农业转居民人数")
  total <- cs("农业人数") + cs("非农业人数") + cs("其他户口人数") + cs("其他") +
           cs("农业转居民人数") + cs("非农业转居民人数") + cs("居民人数") +
           cs("其他(含无户口)人数")
  out <- data.frame(year = year, province = clean_prov(d[[9]]),
                    agri_share = round(agri / total * 100, 1), total = total)
  out <- out[!is.na(out$agri_share) & out$total > 0, ]
  keep_prov(out)[, c("year", "province", "agri_share")]
}

# 2014 tables have a different layout
read_2014 <- function() {
  rd <- function(keyword) {
    raw <- as.data.frame(read_excel(file_for(keyword, 2014), col_names = FALSE))
    nm  <- as.character(unlist(raw[3, ]))
    sub <- as.character(unlist(raw[4, ]))
    cols <- which(!is.na(nm) & sub == "人数")
    d <- raw[5:nrow(raw), ]
    m <- sapply(cols, function(c) as.numeric(d[[c]]))
    colnames(m) <- gsub("~", "-", nm[cols])
    list(prov = clean_prov(d[[1]]), m = m)
  }
  mar <- rd("婚姻"); gen <- rd("性别"); age <- rd("年龄"); edu <- rd("教育"); huk <- rd("户口")
  pick <- function(x, pattern) rowSums(x$m[, grepl(pattern, colnames(x$m)), drop = FALSE])
  out <- data.frame(
    year = 2014, province = mar$prov,
    never_rate  = round(mar$m[, "未婚"] / rowSums(mar$m) * 100, 2),
    first_rate  = round(mar$m[, "初婚"] / rowSums(mar$m) * 100, 2),
    n_marital   = rowSums(mar$m),
    sex_ratio   = round(gen$m[, "男"] / gen$m[, "女"] * 100, 1),
    n_gender    = rowSums(gen$m),
    young_share = round(pick(age, "15-19|20-24|25-29") / rowSums(age$m) * 100, 1),
    higher_edu  = round(pick(edu, "专科|本科|研究生") / rowSums(edu$m) * 100, 1),
    agri_share  = round(pick(huk, "^农业$|^农业转居民$") / rowSums(huk$m) * 100, 1)
  )
  out[!(out$province %in% c("合计", "兵团")) & !is.na(out$never_rate), ]
}

1.3 Panel

stack <- function(reader, keyword) bind_rows(lapply(analysis_years, function(y) {
  f <- file_for(keyword, y)
  if (is.na(f)) return(NULL)
  reader(f, y)
}))

marriage <- stack(read_marriage, "婚姻")
gender   <- stack(read_gender,   "性别")
age      <- stack(read_age,      "年龄")
edu      <- stack(read_edu,      "教育")
hukou    <- stack(read_hukou,    "户口")

name_map <- c(
  "安徽"="Anhui","北京"="Beijing","福建"="Fujian","甘肃"="Gansu",
  "广东"="Guangdong","广西"="Guangxi","贵州"="Guizhou","海南"="Hainan",
  "河北"="Hebei","河南"="Henan","黑龙江"="Heilongjiang","湖北"="Hubei",
  "湖南"="Hunan","吉林"="Jilin","江苏"="Jiangsu","江西"="Jiangxi",
  "辽宁"="Liaoning","内蒙古"="Inner Mongolia","宁夏"="Ningxia",
  "青海"="Qinghai","山东"="Shandong","山西"="Shanxi","陕西"="Shaanxi",
  "上海"="Shanghai","四川"="Sichuan","天津"="Tianjin","西藏"="Tibet",
  "新疆"="Xinjiang","云南"="Yunnan","浙江"="Zhejiang","重庆"="Chongqing"
)
# Inner Mongolia counted as Central, Guangxi as West
east    <- c("Beijing","Tianjin","Hebei","Liaoning","Shanghai","Jiangsu",
             "Zhejiang","Fujian","Shandong","Guangdong","Hainan")
central <- c("Shanxi","Jilin","Heilongjiang","Anhui","Jiangxi","Henan",
             "Hubei","Hunan","Inner Mongolia")

finish <- function(p) {
  p$prov_en <- unname(name_map[p$province])
  p <- p[!is.na(p$prov_en), ]
  p <- p[!duplicated(p[, c("year", "prov_en")]), ]
  p$region <- ifelse(p$prov_en %in% east, "East",
                     ifelse(p$prov_en %in% central, "Central", "West"))
  p
}

panel <- marriage %>%
  left_join(gender, by = c("year", "province")) %>%
  left_join(age,    by = c("year", "province")) %>%
  left_join(edu,    by = c("year", "province")) %>%
  left_join(hukou,  by = c("year", "province")) %>%
  finish()

p2014 <- finish(read_2014())
panel <- rbind(panel, p2014[, names(panel)])
panel <- panel[order(panel$year, panel$prov_en), ]
rownames(panel) <- NULL

cat("Panel rows:", nrow(panel), "| provinces:", length(unique(panel$prov_en)),
    "| years:", paste(sort(unique(panel$year)), collapse = ","), "\n")
Panel rows: 248 | provinces: 31 | years: 2011,2012,2013,2014,2015,2016,2017,2018 
stopifnot(nrow(panel) == 248, length(unique(panel$prov_en)) == 31,
          !anyNA(panel[, c("never_rate", "young_share", "sex_ratio", "higher_edu", "agri_share")]))

vars <- c("never_rate", "young_share", "sex_ratio", "higher_edu", "agri_share")
core <- c("young_share", "sex_ratio", "higher_edu", "agri_share")

2. Data checks

cat("Table totals by year\n")
Table totals by year
print(aggregate(cbind(n_marital, n_gender) ~ year, data = panel, FUN = sum))
  year n_marital n_gender
1 2011    126000   126000
2 2012    154605   154605
3 2013    194795   194795
4 2014    196937   196938
5 2015    203998   203998
6 2016    346352   440017
7 2017    348037   445392
8 2018    149311   151757
cat("\nMarital-table total by province (selected)\n")

Marital-table total by province (selected)
sel <- c("Guangdong", "Zhejiang", "Anhui", "Hebei", "Ningxia", "Tibet")
print(round(xtabs(n_marital ~ prov_en + year, data = panel[panel$prov_en %in% sel, ])))
           year
prov_en      2011  2012  2013  2014  2015  2016  2017  2018
  Anhui      4000  3999  5000  5000  5000  4656  4350  1892
  Guangdong 10000 11992 12000 11998 15000 90253 95272 44303
  Hebei      4000  3994  8000  4998  9998  2777  2585  1037
  Ningxia    2000  3996  4000  3997  4000   716   622   259
  Tibet      2000  3871  3999  3984  4000   412   326   231
  Zhejiang   6000  9995 14000 13999 14000 55492 62874 26967
wide <- function(d, v) { w <- reshape(d[, c("prov_en", "year", v)], idvar = "prov_en",
                                      timevar = "year", direction = "wide"); rownames(w) <- w$prov_en; w[, -1] }
cat("\n2014: cross-province correlation with neighbouring years\n")

2014: cross-province correlation with neighbouring years
chk <- t(sapply(vars, function(v) { w <- wide(panel, v)
  c(r_2013_2014 = cor(w[[paste0(v, ".2013")]], w[[paste0(v, ".2014")]]),
    r_2014_2015 = cor(w[[paste0(v, ".2014")]], w[[paste0(v, ".2015")]]),
    r_2012_2013 = cor(w[[paste0(v, ".2012")]], w[[paste0(v, ".2013")]])) }))
print(round(chk, 2))
            r_2013_2014 r_2014_2015 r_2012_2013
never_rate         0.95        0.92        0.93
young_share        0.86        0.83        0.85
sex_ratio          0.78        0.68        0.88
higher_edu         0.95        0.96        0.92
agri_share         0.97        0.98        0.96
ws <- wide(panel, "sex_ratio")
ratio14 <- ws$sex_ratio.2014 / ((ws$sex_ratio.2013 + ws$sex_ratio.2015) / 2)
names(ratio14) <- rownames(ws)
cat("2014 sex ratio / mean of 2013 and 2015:\n"); print(round(sort(ratio14), 2))
2014 sex ratio / mean of 2013 and 2015:
      Shanghai      Guangdong       Shandong          Tibet         Fujian 
          1.00           1.09           1.11           1.12           1.15 
         Henan       Zhejiang        Jiangsu        Jiangxi          Hunan 
          1.15           1.16           1.19           1.21           1.21 
         Hebei       Xinjiang       Liaoning        Shaanxi         Yunnan 
          1.21           1.21           1.24           1.24           1.25 
        Hainan Inner Mongolia        Sichuan      Chongqing        Guangxi 
          1.26           1.27           1.27           1.27           1.27 
         Anhui          Gansu   Heilongjiang        Qinghai          Jilin 
          1.30           1.30           1.32           1.33           1.36 
         Hubei         Shanxi        Ningxia        Tianjin        Beijing 
          1.37           1.38           1.39           1.39           1.40 
       Guizhou 
          1.46 
cat("Provinces >10% above:", sum(ratio14 > 1.10), "of 31 | range:", round(range(ratio14), 2), "\n")
Provinces >10% above: 29 of 31 | range: 1 1.46 
# Keep the raw panel for one robustness check; set the 2014 sex ratio to missing
panel_raw <- panel
panel$sex_ratio[panel$year == 2014] <- NA

cat("\nExtreme sex ratios (2014 excluded)\n")

Extreme sex ratios (2014 excluded)
print(panel[!is.na(panel$sex_ratio) & (panel$sex_ratio > 150 | panel$sex_ratio < 80), c("year", "prov_en", "sex_ratio")])
    year  prov_en sex_ratio
10  2011    Hebei     165.4
23  2011 Shandong      79.1
41  2012    Hebei     169.9
54  2012 Shandong      77.2
72  2013    Hebei     164.1
85  2013 Shandong      77.3
152 2015    Tibet     152.4
# Within-year z-scores
zpanel <- panel
for (v in vars) zpanel[[v]] <- ave(panel[[v]], panel$year,
                                   FUN = function(x) (x - mean(x, na.rm = TRUE)) / sd(x, na.rm = TRUE))

3. Change over time and provincial differences

trend <- aggregate(panel_raw[, c("never_rate", "first_rate", core)], by = list(year = panel_raw$year), FUN = mean)
trend[, -1] <- round(trend[, -1], 1)
print(trend)
  year never_rate first_rate young_share sex_ratio higher_edu agri_share
1 2011       20.6       77.7        38.9     116.0        7.6       84.8
2 2012       21.7       75.0        38.9     117.0        8.7       84.6
3 2013       21.5       75.2        38.1     118.3        9.4       84.7
4 2014       21.7       74.5        38.7     145.6       12.8       84.7
5 2015       18.1       77.6        37.5     113.8       12.4       84.3
6 2016       16.8       79.4        33.7     109.4       12.4       84.1
7 2017       15.9       79.6        31.1     108.2       13.5       82.9
8 2018       12.0       81.7        25.1     105.9       17.3       81.3
y2018 <- panel[panel$year == 2018, c("prov_en", "never_rate")]
y2018 <- y2018[order(y2018$never_rate), ]
cat("\n2018 lowest five:\n");  print(head(y2018, 5))

2018 lowest five:
           prov_en never_rate
218          Anhui       3.49
232 Inner Mongolia       5.20
230          Hubei       6.25
237        Ningxia       6.56
242         Shanxi       6.66
cat("2018 highest five:\n");   print(tail(y2018, 5))
2018 highest five:
      prov_en never_rate
247    Yunnan      15.45
234   Jiangxi      15.60
223 Guangdong      18.45
229     Henan      19.03
245     Tibet      35.06
cat("2018 range:", round(diff(range(y2018$never_rate)), 1), "pp | without Tibet:",
    round(diff(range(y2018$never_rate[y2018$prov_en != "Tibet"])), 1), "pp\n")
2018 range: 31.6 pp | without Tibet: 15.5 pp
w <- reshape(panel[panel$year %in% c(2011, 2018), c("prov_en", "year", "never_rate")],
             idvar = "prov_en", timevar = "year", direction = "wide")
w$change <- round(w$never_rate.2018 - w$never_rate.2011, 1)
w <- w[order(w$change), c("prov_en", "change")]
cat("\nChange 2011-2018, largest falls:\n"); print(head(w, 5))

Change 2011-2018, largest falls:
    prov_en change
19 Liaoning  -18.8
12    Henan  -16.2
4    Fujian  -16.0
31 Zhejiang  -13.1
10    Hebei  -12.6
cat("Smallest falls / rises:\n"); print(tail(w, 5))
Smallest falls / rises:
    prov_en change
7   Guangxi   -5.0
1     Anhui   -4.3
20  Ningxia   -3.5
29 Xinjiang   -3.0
28    Tibet    2.4
desc <- t(sapply(panel[vars], function(x) c(N = sum(!is.na(x)), Mean = mean(x, na.rm = TRUE),
  SD = sd(x, na.rm = TRUE), Min = min(x, na.rm = TRUE), Median = median(x, na.rm = TRUE), Max = max(x, na.rm = TRUE))))
print(round(desc, 1))
              N  Mean   SD  Min Median   Max
never_rate  248  18.5  6.7  3.5   18.1  42.3
young_share 248  35.2  6.3 17.9   36.0  53.3
sex_ratio   217 112.7 12.9 77.2  111.6 169.9
higher_edu  248  11.8  5.8  2.1   10.7  36.1
agri_share  248  83.9  6.7 61.5   85.7  94.4
for (v in vars) { i <- which.min(panel[[v]]); j <- which.max(panel[[v]])
  cat(sprintf("%-12s min: %s %d | max: %s %d\n", v, panel$prov_en[i], panel$year[i], panel$prov_en[j], panel$year[j])) }
never_rate   min: Anhui 2018 | max: Tibet 2014
young_share  min: Inner Mongolia 2018 | max: Henan 2012
sex_ratio    min: Shandong 2012 | max: Hebei 2012
higher_edu   min: Tibet 2013 | max: Beijing 2018
agri_share   min: Beijing 2016 | max: Henan 2015
par(mar = c(4.5, 4.5, 1, 1))
plot(trend$year, trend$first_rate, type = "o", col = "steelblue", lwd = 2, pch = 19,
     ylim = c(0, 100), xlab = "Year", ylab = "Share of floating population (%)", xaxt = "n")
axis(1, 2011:2018)
lines(trend$year, trend$never_rate, type = "o", col = "tomato", lwd = 2, pch = 19)
text(trend$year, trend$first_rate + 5, sprintf("%.1f", trend$first_rate), cex = 0.7)
text(trend$year, trend$never_rate + 5, sprintf("%.1f", trend$never_rate), cex = 0.7)
legend("right", c("First-married", "Never-married"), col = c("steelblue", "tomato"), lwd = 2, pch = 19, bty = "n", cex = 0.8)
Figure 1. Marital composition of the floating population, 2011–2018

Figure 1. Marital composition of the floating population, 2011–2018

par(mar = c(4.5, 9, 1, 2))
bp <- barplot(y2018$never_rate, names.arg = y2018$prov_en, horiz = TRUE, las = 1, col = "steelblue",
              border = NA, cex.names = 0.85, xlab = "Never-married share, 2018 (%)", xlim = c(0, 40))
text(y2018$never_rate + 1.8, bp, sprintf("%.1f", y2018$never_rate), cex = 0.75)
abline(v = mean(y2018$never_rate), lty = 2)
text(mean(y2018$never_rate), max(bp) + 1, sprintf("mean %.1f", mean(y2018$never_rate)), pos = 4, cex = 0.75, xpd = NA)
Figure 2. Never-married share by province, 2018

Figure 2. Never-married share by province, 2018

4. Correlations

cm <- cor(panel[vars], use = "pairwise.complete.obs")
print(round(cm, 2))
            never_rate young_share sex_ratio higher_edu agri_share
never_rate        1.00        0.62      0.41      -0.34       0.18
young_share       0.62        1.00      0.18      -0.30       0.40
sex_ratio         0.41        0.18      1.00      -0.42       0.18
higher_edu       -0.34       -0.30     -0.42       1.00      -0.62
agri_share        0.18        0.40      0.18      -0.62       1.00
cat("N per variable:", colSums(!is.na(panel[vars])), "\n")
N per variable: 248 248 217 248 248 
cat("\nAfter within-year standardisation:\n")

After within-year standardisation:
print(round(cor(zpanel[vars], use = "pairwise.complete.obs")["never_rate", core], 2))
young_share   sex_ratio  higher_edu  agri_share 
       0.43        0.34       -0.20        0.16 
cat("R2 of youth share alone (N = 248):", round(summary(lm(never_rate ~ young_share, panel))$r.squared, 2), "\n")
R2 of youth share alone (N = 248): 0.38 
cat("Province means (31 provinces):\n")
Province means (31 provinces):
prov_avg <- aggregate(panel[vars], by = list(prov_en = panel$prov_en), FUN = mean, na.rm = TRUE)
rownames(prov_avg) <- prov_avg$prov_en
print(round(cor(prov_avg[vars])["never_rate", core], 2))
young_share   sex_ratio  higher_edu  agri_share 
       0.39        0.40       -0.21        0.14 
partial_corr <- function(df, x, y, z) {
  d <- df[complete.cases(df[, c(x, y, z)]), ]
  cor(residuals(lm(d[[x]] ~ d[[z]])), residuals(lm(d[[y]] ~ d[[z]])))
}
raw_r <- sapply(core[-1], function(v) cor(panel$never_rate, panel[[v]], use = "complete.obs"))
par_r <- sapply(core[-1], function(v) partial_corr(panel, v, "never_rate", "young_share"))
cat("\nRaw vs partial (controlling for youth share):\n")

Raw vs partial (controlling for youth share):
print(round(rbind(raw = raw_r, partial = par_r), 2))
        sex_ratio higher_edu agri_share
raw          0.41      -0.34       0.18
partial      0.39      -0.21      -0.09
blue <- rgb(0.27, 0.51, 0.71, 0.55)
sc <- function(x, xlab) {
  par(mar = c(4.5, 4.5, 1, 1))
  d <- panel_raw; used <- !(x == "sex_ratio" & d$year == 2014)
  plot(d[[x]], d$never_rate, type = "n", xlab = xlab, ylab = "Never-married share (%)")
  points(d[[x]][used], d$never_rate[used], pch = 21, bg = blue, col = "white")
  if (any(!used)) {
    points(d[[x]][!used], d$never_rate[!used], pch = 1, col = "grey55")
    legend("bottomright", c("Used", "2014 (not used)"), pch = c(21, 1), pt.bg = c(blue, NA),
           col = c("white", "grey55"), bty = "n", cex = 0.8)
  }
  abline(lm(d$never_rate[used] ~ d[[x]][used]), lwd = 2, lty = 2)
  legend("topleft", bty = "n", cex = 0.9, legend = sprintf("r = %.2f  (N = %d)",
         cor(d[[x]][used], d$never_rate[used]), sum(used)))
}
sc("young_share", "Youth share, ages 15-29 (%)")
sc("sex_ratio",   "Sex ratio (males per 100 females)")
sc("higher_edu",  "Tertiary-education share (%)")
sc("agri_share",  "Agricultural-hukou share (%)")

lab5 <- c("Never-married", "Youth 15-29", "Sex ratio", "Tertiary edu.", "Agri. hukou")
par(mar = c(7, 7, 1, 1))
image(1:5, 1:5, t(cm[5:1, ]), axes = FALSE, xlab = "", ylab = "",
      col = colorRampPalette(c("steelblue", "white", "tomato"))(50), zlim = c(-1, 1))
axis(1, 1:5, lab5, las = 2, cex.axis = 0.75); axis(2, 1:5, rev(lab5), las = 1, cex.axis = 0.75)
for (i in 1:5) for (j in 1:5) text(j, 6 - i, sprintf("%.2f", cm[i, j]), cex = 0.8)
Figure 7. Correlation matrix

Figure 7. Correlation matrix

rp <- rbind(raw_r, par_r)
bp <- barplot(rp, beside = TRUE, ylim = c(-0.5, 0.6),
              names.arg = c("Sex ratio", "Tertiary education", "Agricultural hukou"),
              col = c("steelblue", "tomato"), border = NA, ylab = "Correlation with never-married share")
text(bp, rp + ifelse(rp >= 0, 0.04, -0.04), sprintf("%+.2f", rp), cex = 0.7)
legend("topright", c("Raw correlation", "Controlling for youth share"), fill = c("steelblue", "tomato"), border = NA, bty = "n", cex = 0.8)
abline(h = 0)
Figure 8. Correlations before and after controlling for the youth share

Figure 8. Correlations before and after controlling for the youth share

5. Regressions

Inference uses province-clustered standard errors (CR1, t with G − 1 df) and wild cluster bootstrap p-values (WCR, Rademacher weights, null imposed; Cameron, Gelbach & Miller, 2008).

reg <- panel[complete.cases(panel[, vars]), c(vars, "year", "prov_en", "n_marital")]
stopifnot(nrow(reg) == 217)

cl_vcov <- function(X, e, g, w) {
  n <- nrow(X); k <- ncol(X); G <- length(unique(g))
  bread <- solve(crossprod(X * sqrt(w)))
  S <- rowsum(X * (w * e), g)
  bread %*% crossprod(S) %*% bread * (G / (G - 1)) * ((n - 1) / (n - k))
}

wild_p <- function(X, y, g, w, j, t0, B) {
  Xr <- X[, -j, drop = FALSE]
  br <- solve(crossprod(Xr * sqrt(w)), crossprod(Xr, w * y))
  yr <- drop(Xr %*% br); er <- y - yr
  A  <- solve(crossprod(X * sqrt(w)), t(X * w))
  gi <- as.integer(factor(g)); G <- max(gi)
  tb <- numeric(B)
  for (b in seq_len(B)) {
    ys <- yr + er * sample(c(-1, 1), G, replace = TRUE)[gi]
    bs <- A %*% ys; es <- ys - drop(X %*% bs)
    tb[b] <- bs[j] / sqrt(cl_vcov(X, es, g, w)[j, j])
  }
  mean(abs(tb) >= abs(t0))
}

fit_table <- function(formula, dat, w = NULL, B = 9999, seed = 2026) {
  if (is.null(w)) w <- rep(1, nrow(dat))
  dat$.w <- w
  m <- lm(formula, data = dat, weights = .w)
  X <- model.matrix(m); y <- dat$never_rate; g <- dat$prov_en; G <- length(unique(g))
  b <- coef(m); V <- cl_vcov(X, residuals(m), g, w); se <- sqrt(diag(V))
  set.seed(seed)
  out <- t(sapply(core, function(v) {
    j <- which(colnames(X) == v); t0 <- b[j] / se[j]
    c(Estimate = unname(b[j]), SE_cluster = unname(se[j]),
      CI_low = unname(b[j] - qt(0.975, G - 1) * se[j]), CI_high = unname(b[j] + qt(0.975, G - 1) * se[j]),
      p_cluster = unname(2 * pt(-abs(t0), G - 1)),
      p_wild = wild_p(X, y, g, w, j, unname(t0), B),
      p_ordinary = summary(m)$coefficients[v, "Pr(>|t|)"])
  }))
  list(model = m, table = round(out, 3), r2 = summary(m)$r.squared)
}

show_fit <- function(res, label, n) {
  cat("\n", label, " | N = ", n, " | R2 = ", round(res$r2, 3), "\n", sep = ""); print(res$table)
}

5.1 Main models

f1 <- never_rate ~ young_share + sex_ratio + higher_edu + agri_share
specs <- list("Model 1: pooled OLS" = f1,
              "Model 2: + year FE"  = update(f1, . ~ . + factor(year)),
              "Model 3: two-way FE" = update(f1, . ~ . + factor(year) + factor(prov_en)))
fits <- list()
for (nm in names(specs)) {
  fits[[nm]] <- fit_table(specs[[nm]], reg)
  show_fit(fits[[nm]], nm, nrow(reg))
}

Model 1: pooled OLS | N = 217 | R2 = 0.513
            Estimate SE_cluster CI_low CI_high p_cluster p_wild p_ordinary
young_share    0.599      0.097  0.401   0.797     0.000  0.000      0.000
sex_ratio      0.129      0.037  0.054   0.204     0.001  0.025      0.000
higher_edu    -0.222      0.128 -0.484   0.040     0.093  0.086      0.003
agri_share    -0.201      0.121 -0.448   0.047     0.108  0.137      0.001

Model 2: + year FE | N = 217 | R2 = 0.564
            Estimate SE_cluster CI_low CI_high p_cluster p_wild p_ordinary
young_share    0.908      0.146  0.610   1.205     0.000  0.000          0
sex_ratio      0.141      0.038  0.063   0.219     0.001  0.024          0
higher_edu    -0.433      0.174 -0.789  -0.077     0.019  0.017          0
agri_share    -0.370      0.138 -0.651  -0.088     0.012  0.021          0

Model 3: two-way FE | N = 217 | R2 = 0.917
            Estimate SE_cluster CI_low CI_high p_cluster p_wild p_ordinary
young_share    0.814      0.117  0.575   1.052     0.000  0.000      0.000
sex_ratio      0.075      0.032  0.009   0.141     0.027  0.007      0.000
higher_edu    -0.112      0.122 -0.361   0.137     0.365  0.363      0.235
agri_share    -0.152      0.099 -0.354   0.049     0.134  0.200      0.055
cat("\nYear effects in Model 2 (reference 2011):\n")

Year effects in Model 2 (reference 2011):
yc <- coef(fits[["Model 2: + year FE"]]$model); print(round(yc[grepl("factor\\(year\\)", names(yc))], 2))
factor(year)2012 factor(year)2013 factor(year)2015 factor(year)2016 
            1.40             2.02             0.94             3.67 
factor(year)2017 factor(year)2018 
            5.32             8.23 
dm <- function(v) residuals(lm(v ~ factor(year) + factor(prov_en), data = reg))
within <- lm(dm(reg$never_rate) ~ dm(reg$young_share) + dm(reg$sex_ratio) + dm(reg$higher_edu) + dm(reg$agri_share))
cat("Within R2 of Model 3:", round(summary(within)$r.squared, 3), "\n")
Within R2 of Model 3: 0.509 

5.2 Robustness checks (Model 3)

m3f <- specs[["Model 3: two-way FE"]]
wrel <- reg$n_marital / ave(reg$n_marital, reg$year)          # sample size relative to its year
rob <- list(
  "Weighted by relative sample size" = list(d = reg, w = wrel),
  "Without 2016-2017"                = list(d = reg[!reg$year %in% c(2016, 2017), ]),
  "Without Tibet"                    = list(d = reg[reg$prov_en != "Tibet", ]),
  "2011-2015 only"                   = list(d = reg[reg$year <= 2015, ]),
  "Adding 2014 with its raw sex ratio" = list(d = panel_raw[, c(vars, "year", "prov_en", "n_marital")]))
rob_fits <- list("Main Model 3" = fits[["Model 3: two-way FE"]])
rob_n    <- c("Main Model 3" = nrow(reg))
for (nm in names(rob)) {
  r <- fit_table(m3f, rob[[nm]]$d, w = rob[[nm]]$w, B = 4999)
  show_fit(r, nm, nrow(rob[[nm]]$d))
  rob_fits[[nm]] <- r; rob_n[nm] <- nrow(rob[[nm]]$d)
}

Weighted by relative sample size | N = 217 | R2 = 0.926
            Estimate SE_cluster CI_low CI_high p_cluster p_wild p_ordinary
young_share    0.772      0.081  0.606   0.938     0.000  0.000      0.000
sex_ratio      0.044      0.026 -0.010   0.097     0.106  0.066      0.017
higher_edu     0.001      0.143 -0.290   0.293     0.992  0.991      0.987
agri_share    -0.043      0.142 -0.334   0.247     0.762  0.762      0.578

Without 2016-2017 | N = 155 | R2 = 0.943
            Estimate SE_cluster CI_low CI_high p_cluster p_wild p_ordinary
young_share    0.643      0.149  0.338   0.948     0.000  0.000      0.000
sex_ratio      0.054      0.032 -0.012   0.119     0.104  0.046      0.019
higher_edu    -0.023      0.149 -0.328   0.281     0.876  0.875      0.830
agri_share    -0.066      0.162 -0.397   0.265     0.687  0.681      0.558

Without Tibet | N = 210 | R2 = 0.909
            Estimate SE_cluster CI_low CI_high p_cluster p_wild p_ordinary
young_share    0.783      0.118  0.541   1.025     0.000  0.000      0.000
sex_ratio      0.076      0.034  0.006   0.145     0.033  0.008      0.000
higher_edu    -0.069      0.121 -0.316   0.178     0.571  0.563      0.466
agri_share    -0.139      0.102 -0.347   0.069     0.181  0.236      0.074

2011-2015 only | N = 124 | R2 = 0.964
            Estimate SE_cluster CI_low CI_high p_cluster p_wild p_ordinary
young_share    0.784      0.093  0.595   0.973     0.000  0.000      0.000
sex_ratio      0.023      0.021 -0.020   0.066     0.282  0.244      0.251
higher_edu    -0.171      0.193 -0.566   0.223     0.382  0.454      0.235
agri_share    -0.074      0.205 -0.492   0.344     0.719  0.697      0.569

Adding 2014 with its raw sex ratio | N = 248 | R2 = 0.92
            Estimate SE_cluster CI_low CI_high p_cluster p_wild p_ordinary
young_share    0.832      0.117  0.593   1.071     0.000  0.000      0.000
sex_ratio      0.068      0.029  0.009   0.127     0.025  0.012      0.000
higher_edu    -0.122      0.133 -0.393   0.149     0.365  0.368      0.169
agri_share    -0.147      0.109 -0.370   0.076     0.188  0.234      0.055
cat("\nSummary by variable\n")

Summary by variable
for (v in core) {
  s <- t(sapply(rob_fits, function(r) r$table[v, c("Estimate", "SE_cluster", "CI_low", "CI_high", "p_cluster", "p_wild")]))
  s <- data.frame(N = rob_n[rownames(s)], s, check.names = FALSE)
  cat("\n", v, ":\n", sep = ""); print(s)
}

young_share:
                                     N Estimate SE_cluster CI_low CI_high
Main Model 3                       217    0.814      0.117  0.575   1.052
Weighted by relative sample size   217    0.772      0.081  0.606   0.938
Without 2016-2017                  155    0.643      0.149  0.338   0.948
Without Tibet                      210    0.783      0.118  0.541   1.025
2011-2015 only                     124    0.784      0.093  0.595   0.973
Adding 2014 with its raw sex ratio 248    0.832      0.117  0.593   1.071
                                   p_cluster p_wild
Main Model 3                               0      0
Weighted by relative sample size           0      0
Without 2016-2017                          0      0
Without Tibet                              0      0
2011-2015 only                             0      0
Adding 2014 with its raw sex ratio         0      0

sex_ratio:
                                     N Estimate SE_cluster CI_low CI_high
Main Model 3                       217    0.075      0.032  0.009   0.141
Weighted by relative sample size   217    0.044      0.026 -0.010   0.097
Without 2016-2017                  155    0.054      0.032 -0.012   0.119
Without Tibet                      210    0.076      0.034  0.006   0.145
2011-2015 only                     124    0.023      0.021 -0.020   0.066
Adding 2014 with its raw sex ratio 248    0.068      0.029  0.009   0.127
                                   p_cluster p_wild
Main Model 3                           0.027  0.007
Weighted by relative sample size       0.106  0.066
Without 2016-2017                      0.104  0.046
Without Tibet                          0.033  0.008
2011-2015 only                         0.282  0.244
Adding 2014 with its raw sex ratio     0.025  0.012

higher_edu:
                                     N Estimate SE_cluster CI_low CI_high
Main Model 3                       217   -0.112      0.122 -0.361   0.137
Weighted by relative sample size   217    0.001      0.143 -0.290   0.293
Without 2016-2017                  155   -0.023      0.149 -0.328   0.281
Without Tibet                      210   -0.069      0.121 -0.316   0.178
2011-2015 only                     124   -0.171      0.193 -0.566   0.223
Adding 2014 with its raw sex ratio 248   -0.122      0.133 -0.393   0.149
                                   p_cluster p_wild
Main Model 3                           0.365  0.363
Weighted by relative sample size       0.992  0.991
Without 2016-2017                      0.876  0.875
Without Tibet                          0.571  0.563
2011-2015 only                         0.382  0.454
Adding 2014 with its raw sex ratio     0.365  0.368

agri_share:
                                     N Estimate SE_cluster CI_low CI_high
Main Model 3                       217   -0.152      0.099 -0.354   0.049
Weighted by relative sample size   217   -0.043      0.142 -0.334   0.247
Without 2016-2017                  155   -0.066      0.162 -0.397   0.265
Without Tibet                      210   -0.139      0.102 -0.347   0.069
2011-2015 only                     124   -0.074      0.205 -0.492   0.344
Adding 2014 with its raw sex ratio 248   -0.147      0.109 -0.370   0.076
                                   p_cluster p_wild
Main Model 3                           0.134  0.200
Weighted by relative sample size       0.762  0.762
Without 2016-2017                      0.687  0.681
Without Tibet                          0.181  0.236
2011-2015 only                         0.719  0.697
Adding 2014 with its raw sex ratio     0.188  0.234

5.3 Diagnostics

vif_of <- function(d) sapply(core, function(v)
  1 / (1 - summary(lm(reformulate(setdiff(core, v), response = v), data = d))$r.squared))
cat("VIF, pooled\n"); print(round(vif_of(reg), 2))
VIF, pooled
young_share   sex_ratio  higher_edu  agri_share 
       1.23        1.24        1.97        1.77 
dmd <- as.data.frame(lapply(reg[core], dm))
cat("VIF, after year and province fixed effects\n"); print(round(vif_of(dmd), 2))
VIF, after year and province fixed effects
young_share   sex_ratio  higher_edu  agri_share 
       1.09        1.09        1.53        1.54 
m3 <- fits[["Model 3: two-way FE"]]$model
big <- order(-abs(residuals(m3)))[1:5]
cat("\nFive largest residuals of Model 3\n")

Five largest residuals of Model 3
print(data.frame(reg[big, c("year", "prov_en", "never_rate")], residual = round(residuals(m3)[big], 1)))
    year  prov_en never_rate residual
81  2013 Liaoning      36.78      6.6
240 2018 Shandong       7.18      6.0
147 2015 Shandong       7.87     -5.1
245 2018    Tibet      35.06      4.9
225 2018  Guizhou      10.69     -4.4
par(mfrow = c(1, 2), mar = c(4.5, 4.5, 2, 1))
plot(fitted(m3), residuals(m3), pch = 21, bg = blue, col = "white",
     xlab = "Fitted never-married share (%)", ylab = "Residual", main = "Residuals vs fitted")
abline(h = 0, lty = 2)
qqnorm(residuals(m3), pch = 21, bg = blue, col = "white", main = "Normal Q-Q"); qqline(residuals(m3), lty = 2)
Figure 9. Residual diagnostics for Model 3

Figure 9. Residual diagnostics for Model 3

5.4 Inference checks for the sex ratio

G   <- length(unique(reg$prov_en))
tcr <- qt(0.975, G - 1)

X3 <- model.matrix(m3); e3 <- residuals(m3); gi <- as.integer(factor(reg$prov_en))
bread3 <- solve(crossprod(X3)); j_sr <- which(colnames(X3) == "sex_ratio")
S3 <- rowsum(X3 * e3, gi)
contrib <- (S3 %*% bread3[, j_sr])^2
share <- sort(setNames(drop(contrib) / sum(contrib), levels(factor(reg$prov_en))), decreasing = TRUE)
cat("Share of the clustered variance of the sex-ratio coefficient, top 5 provinces\n")
Share of the clustered variance of the sex-ratio coefficient, top 5 provinces
print(round(head(share, 5), 2))
      Liaoning          Hebei       Shandong          Anhui Inner Mongolia 
          0.34           0.23           0.23           0.04           0.02 
loo <- sapply(levels(factor(reg$prov_en)), function(p)
  coef(lm(m3f, data = reg[reg$prov_en != p, ]))["sex_ratio"])
names(loo) <- levels(factor(reg$prov_en))
cat("Coefficient when one province is dropped (range):", round(range(loo), 3), "\n")
Coefficient when one province is dropped (range): 0.056 0.094 
cat("Dropping the three largest contributors:", round(loo[names(head(share, 3))], 3), "\n")
Dropping the three largest contributors: 0.056 0.094 0.057 
cr3_se <- sqrt((G - 1) / G * sum((loo - mean(loo))^2))
cat("CR3 (jackknife) SE:", round(cr3_se, 3),
    "| p =", round(2 * pt(-abs(coef(m3)["sex_ratio"] / cr3_se), G - 1), 3), "\n")
CR3 (jackknife) SE: 0.035 | p = 0.039 
wild_dist <- function(j, B = 9999, restricted = TRUE, webb = FALSE, seed = 2026) {
  set.seed(seed)
  y <- reg$never_rate; b <- coef(m3)
  if (restricted) { Xr <- X3[, -j]; yr <- drop(Xr %*% solve(crossprod(Xr), crossprod(Xr, y))) } else yr <- drop(X3 %*% b)
  er <- y - yr; A <- bread3 %*% t(X3); wts <- if (webb) c(-sqrt(1.5), -1, -sqrt(0.5), sqrt(0.5), 1, sqrt(1.5)) else c(-1, 1)
  sapply(seq_len(B), function(i) {
    ys <- yr + er * sample(wts, G, replace = TRUE)[gi]; bs <- A %*% ys; es <- ys - drop(X3 %*% bs)
    (bs[j] - if (restricted) 0 else b[j]) / sqrt(cl_vcov(X3, es, gi, rep(1, length(es)))[j, j])
  })
}
t_sr <- coef(m3)[j_sr] / sqrt(cl_vcov(X3, e3, gi, rep(1, length(e3)))[j_sr, j_sr])
for (cfg in list(c("WCR, Rademacher", TRUE, FALSE), c("WCR, Webb", TRUE, TRUE), c("WCU, Rademacher", FALSE, FALSE))) {
  tb <- wild_dist(j_sr, restricted = as.logical(cfg[2]), webb = as.logical(cfg[3]))
  cat(sprintf("%-16s p = %.3f | SD of t* = %.2f | 95th percentile of |t*| = %.2f (t(30) critical value %.2f)\n",
              cfg[1], mean(abs(tb) >= abs(t_sr)), sd(tb), quantile(abs(tb), 0.95), tcr))
}
WCR, Rademacher  p = 0.006 | SD of t* = 0.97 | 95th percentile of |t*| = 1.83 (t(30) critical value 2.04)
WCR, Webb        p = 0.006 | SD of t* = 0.98 | 95th percentile of |t*| = 1.82 (t(30) critical value 2.04)
WCU, Rademacher  p = 0.041 | SD of t* = 1.10 | 95th percentile of |t*| = 2.26 (t(30) critical value 2.04)
cat("Model 3: 95% CIs (clustered SE with t(G-1); wild bootstrap-t, WCU)\n")
Model 3: 95% CIs (clustered SE with t(G-1); wild bootstrap-t, WCU)
V3 <- cl_vcov(X3, e3, gi, rep(1, length(e3)))
ci_tab <- t(sapply(core, function(v) {
  j <- which(colnames(X3) == v); b <- coef(m3)[j]; s <- sqrt(V3[j, j])
  q <- quantile(abs(wild_dist(j, B = 4999, restricted = FALSE)), 0.95)
  c(Estimate = unname(b), CI_low = unname(b - tcr * s), CI_high = unname(b + tcr * s),
    boot_low = unname(b - q * s), boot_high = unname(b + q * s))
}))
print(round(ci_tab, 3))
            Estimate CI_low CI_high boot_low boot_high
young_share    0.814  0.575   1.052    0.572     1.055
sex_ratio      0.075  0.009   0.141    0.002     0.147
higher_edu    -0.112 -0.361   0.137   -0.345     0.120
agri_share    -0.152 -0.354   0.049   -0.328     0.023
cat("\nCorrelations: 95% CIs from a province bootstrap (2000 resamples)\n")

Correlations: 95% CIs from a province bootstrap (2000 resamples)
pc_stats <- function(d, v) {
  raw <- cor(d$never_rate, d[[v]])
  par <- cor(residuals(lm(d$never_rate ~ d$young_share)), residuals(lm(d[[v]] ~ d$young_share)))
  c(raw = raw, partial = par, change = raw - par)
}
provs <- unique(panel$prov_en); set.seed(2026)
for (v in c("agri_share", "higher_edu", "sex_ratio")) {
  d0 <- panel[complete.cases(panel[, c("never_rate", "young_share", v)]), ]
  est <- pc_stats(d0, v)
  bs <- t(replicate(2000, { pick <- sample(provs, length(provs), replace = TRUE)
    pc_stats(do.call(rbind, lapply(pick, function(p) d0[d0$prov_en == p, ])), v) }))
  ci <- apply(bs, 2, quantile, c(0.025, 0.975))
  cat(sprintf("%-11s raw %.3f [%.3f, %.3f] | partial %.3f [%.3f, %.3f] | change %.3f [%.3f, %.3f]\n", v,
              est[1], ci[1, 1], ci[2, 1], est[2], ci[1, 2], ci[2, 2], est[3], ci[1, 3], ci[2, 3]))
}
agri_share  raw 0.183 [-0.077, 0.394] | partial -0.093 [-0.425, 0.172] | change 0.276 [0.139, 0.464]
higher_edu  raw -0.340 [-0.532, -0.187] | partial -0.205 [-0.449, 0.061] | change -0.134 [-0.323, -0.033]
sex_ratio   raw 0.410 [0.224, 0.560] | partial 0.390 [0.183, 0.538] | change 0.019 [-0.074, 0.135]

6. Between and within provinces

6.1 Mundlak model

mund <- reg
for (v in core) { mund[[paste0(v, "_b")]] <- ave(reg[[v]], reg$prov_en); mund[[paste0(v, "_w")]] <- reg[[v]] - mund[[paste0(v, "_b")]] }
fm <- as.formula(paste("never_rate ~", paste(c(paste0(core, "_w"), paste0(core, "_b")), collapse = " + "), "+ factor(year)"))
mm <- lm(fm, data = mund); Xm <- model.matrix(mm); Vm <- cl_vcov(Xm, residuals(mm), gi, rep(1, nrow(Xm)))
mtab <- t(sapply(core, function(v) {
  jw <- which(colnames(Xm) == paste0(v, "_w")); jb <- which(colnames(Xm) == paste0(v, "_b"))
  bw <- coef(mm)[jw]; bb <- coef(mm)[jb]; sw <- sqrt(Vm[jw, jw]); sb <- sqrt(Vm[jb, jb])
  sd_ <- sqrt(Vm[jb, jb] + Vm[jw, jw] - 2 * Vm[jb, jw])
  c(within = bw, w_low = bw - tcr * sw, w_high = bw + tcr * sw, p_within = 2 * pt(-abs(bw / sw), G - 1),
    between = bb, b_low = bb - tcr * sb, b_high = bb + tcr * sb, p_between = 2 * pt(-abs(bb / sb), G - 1),
    p_difference = 2 * pt(-abs((bb - bw) / sd_), G - 1))
}))
colnames(mtab) <- c("within", "w_low", "w_high", "p_within", "between", "b_low", "b_high", "p_between", "p_diff")
print(round(mtab, 3))
            within  w_low w_high p_within between  b_low b_high p_between
young_share  0.814  0.591  1.036    0.000   0.977  0.485  1.468     0.000
sex_ratio    0.075  0.014  0.136    0.018   0.185  0.047  0.323     0.010
higher_edu  -0.112 -0.345  0.120    0.332  -0.461 -0.889 -0.033     0.036
agri_share  -0.152 -0.340  0.036    0.109  -0.429 -0.819 -0.038     0.033
            p_diff
young_share  0.498
sex_ratio    0.128
higher_edu   0.132
agri_share   0.245
Rm <- matrix(0, 4, ncol(Xm)); for (i in 1:4) { Rm[i, which(colnames(Xm) == paste0(core[i], "_b"))] <- 1; Rm[i, which(colnames(Xm) == paste0(core[i], "_w"))] <- -1 }
rb <- Rm %*% coef(mm); Fj <- drop(t(rb) %*% solve(Rm %*% Vm %*% t(Rm)) %*% rb) / 4
cat("Joint test, between = within for all four: F =", round(Fj, 2), "| p =", round(pf(Fj, 4, G - 1, lower.tail = FALSE), 3), "\n")
Joint test, between = within for all four: F = 1.32 | p = 0.287 
par(mar = c(4.5, 9, 1, 1))
lab4 <- c("Youth share", "Sex ratio", "Tertiary edu.", "Agri. hukou"); yy <- 4:1
plot(NA, xlim = range(mtab[, c("w_low", "w_high", "b_low", "b_high")], 0), ylim = c(0.5, 4.5), yaxt = "n",
     ylab = "", xlab = "Coefficient (percentage points per unit), 95% CI")
axis(2, yy, lab4, las = 1); abline(v = 0, lty = 2)
segments(mtab[, "w_low"], yy + 0.12, mtab[, "w_high"], yy + 0.12, col = "steelblue", lwd = 2); points(mtab[, "within"], yy + 0.12, pch = 19, col = "steelblue")
segments(mtab[, "b_low"], yy - 0.12, mtab[, "b_high"], yy - 0.12, col = "tomato", lwd = 2); points(mtab[, "between"], yy - 0.12, pch = 17, col = "tomato")
legend("bottomright", c("Within provinces", "Between provinces"), col = c("steelblue", "tomato"), pch = c(19, 17), lwd = 2, bty = "n", cex = 0.8)
Figure 10. Within- and between-province coefficients, 95% CI

Figure 10. Within- and between-province coefficients, 95% CI

6.2 Variance decomposition

tot <- sum((reg$never_rate - mean(reg$never_rate))^2)
cat("Between-province share:", round(sum((ave(reg$never_rate, reg$prov_en) - mean(reg$never_rate))^2) / tot, 2),
    "| year share:", round(sum((ave(reg$never_rate, reg$year) - mean(reg$never_rate))^2) / tot, 2), "\n")
Between-province share: 0.57 | year share: 0.26 
r2 <- function(f, d) summary(lm(f, data = d))$r.squared
cat("Between provinces (31 means), R2 of single variables:",
    paste(core, round(sapply(core, function(v) r2(reformulate(v, "never_rate"), prov_avg)), 3)), "\n")
Between provinces (31 means), R2 of single variables: young_share 0.149 sex_ratio 0.161 higher_edu 0.046 agri_share 0.02 
full_b <- r2(reformulate(core, "never_rate"), prov_avg); cat("R2 of all four:", round(full_b, 3), "\n")
R2 of all four: 0.438 
shap <- sapply(core, function(v) { others <- setdiff(core, v); tot_ <- 0
  for (k in 0:3) for (S in if (k == 0) list(character(0)) else combn(others, k, simplify = FALSE)) {
    w <- factorial(length(S)) * factorial(3 - length(S)) / factorial(4)
    with_v <- r2(reformulate(c(S, v), "never_rate"), prov_avg)
    without <- if (length(S)) r2(reformulate(S, "never_rate"), prov_avg) else 0
    tot_ <- tot_ + w * (with_v - without) }
  tot_ })
cat("Shapley shares of the between-province R2:", paste(core, round(shap, 3)), "\n")
Shapley shares of the between-province R2: young_share 0.201 sex_ratio 0.134 higher_edu 0.054 agri_share 0.05 
cat("Within provinces, R2 youth only:",
    round(summary(lm(dm(reg$never_rate) ~ dm(reg$young_share)))$r.squared, 3),
    "| all four:", round(summary(within)$r.squared, 3), "\n")
Within provinces, R2 youth only: 0.451 | all four: 0.509 
cat("Cross-sectional R2 of youth share by year:\n")
Cross-sectional R2 of youth share by year:
print(round(sapply(split(panel, panel$year), function(d) r2(never_rate ~ young_share, d)), 2))
2011 2012 2013 2014 2015 2016 2017 2018 
0.28 0.33 0.19 0.26 0.09 0.03 0.17 0.27 

6.3 Influence of single provinces

between_stats <- function(pa) {
  r  <- sapply(core, function(v) cor(pa$never_rate, pa[[v]]))
  r2 <- sapply(core, function(v) summary(lm(reformulate(v, "never_rate"), data = pa))$r.squared)
  c(setNames(r, paste0("r_", core)), setNames(r2, paste0("R2_", core)),
    R2_all4 = summary(lm(reformulate(core, "never_rate"), data = pa))$r.squared)
}
pa31 <- prov_avg[, c("prov_en", vars)]
print(round(rbind(all_31 = between_stats(pa31),
                  without_Tibet = between_stats(pa31[pa31$prov_en != "Tibet", ]),
                  without_Hebei_Shandong = between_stats(pa31[!pa31$prov_en %in% c("Hebei", "Shandong"), ])), 3))
                       r_young_share r_sex_ratio r_higher_edu r_agri_share
all_31                         0.386       0.401       -0.215        0.140
without_Tibet                  0.472       0.309       -0.054        0.048
without_Hebei_Shandong         0.401       0.305       -0.198        0.169
                       R2_young_share R2_sex_ratio R2_higher_edu R2_agri_share
all_31                          0.149        0.161         0.046         0.020
without_Tibet                   0.223        0.095         0.003         0.002
without_Hebei_Shandong          0.161        0.093         0.039         0.028
                       R2_all4
all_31                   0.438
without_Tibet            0.490
without_Hebei_Shandong   0.382
cat("\nCorrelation of province means when one province is dropped\n")

Correlation of province means when one province is dropped
for (v in core) {
  rr <- sapply(pa31$prov_en, function(p) { d <- pa31[pa31$prov_en != p, ]; cor(d$never_rate, d[[v]]) })
  cat(sprintf("%-11s full %.3f | min %.3f (without %s) | max %.3f (without %s)\n", v,
              cor(pa31$never_rate, pa31[[v]]), min(rr), names(which.min(rr)), max(rr), names(which.max(rr))))
}
young_share full 0.386 | min 0.276 (without Henan) | max 0.472 (without Tibet)
sex_ratio   full 0.401 | min 0.309 (without Tibet) | max 0.470 (without Henan)
higher_edu  full -0.215 | min -0.251 (without Beijing) | max -0.054 (without Tibet)
agri_share  full 0.140 | min 0.044 (without Henan) | max 0.223 (without Liaoning)
cat("\nSpearman correlation of province means\n")

Spearman correlation of province means
print(round(sapply(core, function(v) cor(pa31$never_rate, pa31[[v]], method = "spearman")), 3))
young_share   sex_ratio  higher_edu  agri_share 
      0.428       0.345      -0.113       0.131 
cat("\nMundlak model without Tibet\n")

Mundlak model without Tibet
mund_nt <- mund[mund$prov_en != "Tibet", ]
for (v in core) { mund_nt[[paste0(v, "_b")]] <- ave(mund_nt[[v]], mund_nt$prov_en)
                  mund_nt[[paste0(v, "_w")]] <- mund_nt[[v]] - mund_nt[[paste0(v, "_b")]] }
mm_nt <- lm(fm, data = mund_nt); Xn <- model.matrix(mm_nt); gn <- mund_nt$prov_en; Gn <- length(unique(gn))
Vn <- cl_vcov(Xn, residuals(mm_nt), gn, rep(1, nrow(Xn))); tn <- qt(0.975, Gn - 1)
print(round(t(sapply(core, function(v) {
  jw <- which(colnames(Xn) == paste0(v, "_w")); jb <- which(colnames(Xn) == paste0(v, "_b"))
  bw <- coef(mm_nt)[jw]; bb <- coef(mm_nt)[jb]; sw <- sqrt(Vn[jw, jw]); sb <- sqrt(Vn[jb, jb])
  sdd <- sqrt(Vn[jb, jb] + Vn[jw, jw] - 2 * Vn[jb, jw])
  c(within = unname(bw), w_low = unname(bw - tn * sw), w_high = unname(bw + tn * sw),
    between = unname(bb), b_low = unname(bb - tn * sb), b_high = unname(bb + tn * sb),
    p_between = unname(2 * pt(-abs(bb / sb), Gn - 1)), p_diff = unname(2 * pt(-abs((bb - bw) / sdd), Gn - 1)))
})), 3))
            within  w_low w_high between  b_low b_high p_between p_diff
young_share  0.783  0.557  1.009   1.015  0.546  1.485     0.000  0.291
sex_ratio    0.076  0.011  0.141   0.143  0.030  0.256     0.015  0.307
higher_edu  -0.069 -0.300  0.161  -0.356 -0.726  0.014     0.059  0.207
agri_share  -0.139 -0.333  0.055  -0.448 -0.835 -0.060     0.025  0.195

7. How much variation can age structure explain?

7.1 Five age groups

grp_def <- list(a15_19 = "15-19", a20_24 = "20-24", a25_29 = "25-29",
                a30_39 = c("30-34", "35-39"), a40_59 = c("40-44", "45-49", "50-54", "55-59"))
read_age_groups <- function(file_path, year) {
  raw <- as.data.frame(read_excel(file_path, col_names = FALSE))
  header <- as.character(unlist(raw[2, ])); d <- raw[6:nrow(raw), ]
  num <- function(seg) { h <- which(grepl(seg, header, fixed = TRUE) & grepl("人数", header))
                         if (length(h)) as.numeric(d[[h[1]]]) else rep(0, nrow(d)) }
  out <- data.frame(year = year, province = clean_prov(d[[9]]))
  for (g in names(grp_def)) out[[g]] <- Reduce(`+`, lapply(grp_def[[g]], function(s) num(paste0(s, "岁"))))
  out$a60plus <- num("60岁及以上")
  out$a0_14 <- num("0-4岁") + num("5-9岁") + num("10-14岁")
  out <- out[!is.na(out$a15_19), ]
  keep_prov(out)
}
ag <- bind_rows(lapply(analysis_years, function(y) read_age_groups(file_for("年龄", y), y)))
raw14 <- as.data.frame(read_excel(file_for("年龄", 2014), col_names = FALSE))
nm14 <- gsub("~", "-", as.character(unlist(raw14[3, ]))); sub14 <- as.character(unlist(raw14[4, ]))
d14 <- raw14[5:nrow(raw14), ]
col14 <- function(seg) { h <- which(grepl(seg, nm14, fixed = TRUE) & sub14 == "人数"); as.numeric(d14[[h[1]]]) }
ag14 <- data.frame(year = 2014, province = clean_prov(d14[[1]]))
for (g in names(grp_def)) ag14[[g]] <- Reduce(`+`, lapply(grp_def[[g]], function(s) col14(s)))
ag14$a60plus <- 0; ag14$a0_14 <- 0
ag14 <- ag14[!(ag14$province %in% c("合计", "兵团")) & !is.na(ag14$a15_19), ]
ag <- rbind(ag, ag14)
ag$prov_en <- unname(name_map[ag$province]); ag <- ag[!is.na(ag$prov_en) & !duplicated(ag[, c("year", "prov_en")]), ]
grp <- names(grp_def)
ag$n15_59 <- rowSums(ag[, grp])
for (g in grp) ag[[paste0("s_", g)]] <- ag[[g]] / ag$n15_59
P18 <- merge(panel, ag[, c("year", "prov_en", grp, "a60plus", "a0_14", "n15_59", paste0("s_", grp))],
             by = c("year", "prov_en"))
stopifnot(nrow(P18) == 248)
cat("Youth share rebuilt from age groups, max abs difference:",
    round(max(abs(100 * (P18$s_a15_19 + P18$s_a20_24 + P18$s_a25_29) - P18$young_share)), 2), "\n")
Youth share rebuilt from age groups, max abs difference: 0.05 
cat("\nDenominators by year\n")

Denominators by year
print(aggregate(cbind(n_marital, age_15_59 = n15_59, age_60plus = a60plus, age_0_14 = a0_14) ~ year, data = P18, FUN = sum))
  year n_marital age_15_59 age_60plus age_0_14
1 2011    126000    126000          0        0
2 2012    154605    154605          0        0
3 2013    194795    194795          0        0
4 2014    196937    196938          0        0
5 2015    203998    199365       4633        0
6 2016    346352    337125      11777    91106
7 2017    348037    337710      15545    92131
8 2018    149311    146331       5426        0

7.2 Composition benchmark and Goodman regression

m_nr <- mean(P18$never_rate) / 100; m_ys <- mean(P18$young_share) / 100
cat(sprintf("Mean never-married share %.3f | mean youth share %.3f | implied r(15-29) if all 30+ married: %.3f\n",
            m_nr, m_ys, m_nr / m_ys))
Mean never-married share 0.185 | mean youth share 0.352 | implied r(15-29) if all 30+ married: 0.526
# Goodman ecological regression, rates bounded in [0, 1]
bls <- function(X, y) {
  f <- function(b) sum((y - X %*% b)^2); g <- function(b) -2 * drop(t(X) %*% (y - X %*% b))
  optim(rep(0.3, ncol(X)), f, g, method = "L-BFGS-B", lower = 0, upper = 1)$par
}
Xs <- as.matrix(P18[, paste0("s_", grp)]); ynr <- P18$never_rate / 100
b_pool <- setNames(bls(Xs, ynr), grp)
b_ols  <- setNames(coef(lm(ynr ~ 0 + Xs)), grp)
b_year <- t(sapply(sort(unique(P18$year)), function(y) { k <- P18$year == y; bls(Xs[k, ], ynr[k]) }))
dimnames(b_year) <- list(sort(unique(P18$year)), grp)
cat("\nImplied never-married rate by age group (248 province-years)\n")

Implied never-married rate by age group (248 province-years)
print(round(rbind(pooled_bounded = b_pool, pooled_unconstrained = b_ols), 3))
                     a15_19 a20_24 a25_29 a30_39 a40_59
pooled_bounded        0.725  1.000  0.000  0.000  0.098
pooled_unconstrained  0.269  1.469 -0.208 -0.062  0.179
cat("By year (bounded):\n"); print(round(b_year, 3))
By year (bounded):
     a15_19 a20_24 a25_29 a30_39 a40_59
2011  0.997  0.859  0.000  0.000  0.082
2012  0.805  1.000  0.000  0.000  0.091
2013  1.000  0.975  0.000  0.000  0.109
2014  1.000  1.000  0.000  0.000  0.138
2015  0.952  0.831  0.000  0.000  0.125
2016  0.627  0.482  0.000  0.012  0.212
2017  0.348  1.000  0.000  0.044  0.090
2018  1.000  1.000  0.089  0.000  0.054
P18$expected <- 100 * drop(Xs %*% b_pool)
P18$expected_y <- 100 * sapply(seq_len(nrow(P18)), function(i) sum(Xs[i, ] * b_year[as.character(P18$year[i]), ]))
r2v <- function(y, f) 1 - sum((y - f)^2) / sum((y - mean(y))^2)
pm <- aggregate(cbind(never_rate, expected, expected_y) ~ prov_en, data = P18, FUN = mean)
cat(sprintf("R2 of expected vs actual: all province-years %.3f (pooled rates) / %.3f (year-specific rates)\n",
            r2v(P18$never_rate, P18$expected), r2v(P18$never_rate, P18$expected_y)))
R2 of expected vs actual: all province-years 0.602 (pooled rates) / 0.648 (year-specific rates)
cat(sprintf("Between provinces (31 means): R2 = %.3f / %.3f; correlation = %.3f / %.3f\n",
            r2v(pm$never_rate, pm$expected), r2v(pm$never_rate, pm$expected_y),
            cor(pm$never_rate, pm$expected), cor(pm$never_rate, pm$expected_y)))
Between provinces (31 means): R2 = 0.492 / 0.511; correlation = 0.738 / 0.753
pm$gap <- round(pm$never_rate - pm$expected_y, 1); pm <- pm[order(pm$gap), ]
cat("Actual minus age-expected (year-specific rates), lowest 5:\n"); print(head(pm[, c("prov_en", "gap")], 5))
Actual minus age-expected (year-specific rates), lowest 5:
   prov_en  gap
1    Anhui -6.1
13   Hubei -5.1
16 Jiangsu -3.7
20 Ningxia -3.7
21 Qinghai -3.6
cat("Highest 5:\n"); print(tail(pm[, c("prov_en", "gap")], 5))
Highest 5:
    prov_en  gap
7   Guangxi  2.8
10    Hebei  3.3
12    Henan  5.0
19 Liaoning  6.4
28    Tibet 12.4
pma <- aggregate(P18[, c("never_rate", paste0("s_", grp))], by = list(prov_en = P18$prov_en), FUN = mean)
fa <- lm(reformulate(paste0("s_", grp[-5]), "never_rate"), data = pma)
cat(sprintf("Between provinces, OLS on four age-group shares: R2 = %.3f, adjusted R2 = %.3f (youth share alone: R2 = %.3f)\n",
            summary(fa)$r.squared, summary(fa)$adj.r.squared,
            summary(lm(never_rate ~ young_share, data = aggregate(P18[, c("never_rate", "young_share")], by = list(p = P18$prov_en), FUN = mean)))$r.squared))
Between provinces, OLS on four age-group shares: R2 = 0.688, adjusted R2 = 0.640 (youth share alone: R2 = 0.149)
P18$young1519 <- P18$a15_19 / (P18$a15_19 + P18$a20_24 + P18$a25_29)
cat(sprintf("Correlation of youth share with the 15-19 share among ages 15-29: pooled %.3f | province means %.3f | within %.3f\n",
            cor(P18$young_share, P18$young1519),
            with(aggregate(cbind(young_share, young1519) ~ prov_en, P18, mean), cor(young_share, young1519)),
            cor(residuals(lm(young_share ~ factor(year) + factor(prov_en), P18)), residuals(lm(young1519 ~ factor(year) + factor(prov_en), P18)))))
Correlation of youth share with the 15-19 share among ages 15-29: pooled 0.200 | province means 0.106 | within 0.169

7.3 Variance explained by the youth share

var_parts <- function(d) {
  tot <- sum((d$never_rate - mean(d$never_rate))^2)
  bp <- sum((ave(d$never_rate, d$prov_en) - mean(d$never_rate))^2); by <- sum((ave(d$never_rate, d$year) - mean(d$never_rate))^2)
  c(province = bp / tot, year = by / tot, remainder = 1 - (bp + by) / tot)
}
print(round(rbind(N248 = var_parts(panel), N217 = var_parts(reg)), 3))
     province  year remainder
N248    0.593 0.245     0.162
N217    0.570 0.261     0.169
vp <- var_parts(reg)
pm217 <- aggregate(reg[, vars], by = list(prov_en = reg$prov_en), FUN = mean)
ym217 <- aggregate(reg[, vars], by = list(year = reg$year), FUN = mean)
r2_b <- summary(lm(never_rate ~ young_share, pm217))$r.squared
r2_y <- summary(lm(never_rate ~ young_share, ym217))$r.squared
r2_w <- summary(lm(dm(reg$never_rate) ~ dm(reg$young_share)))$r.squared
cat(sprintf("Youth share, R2 within each component: between provinces %.3f | between years %.3f | within %.3f\n", r2_b, r2_y, r2_w))
Youth share, R2 within each component: between provinces 0.141 | between years 0.919 | within 0.451
cat(sprintf("As a share of total variance: between provinces %.3f | between years %.3f | within %.3f\n",
            r2_b * vp["province"], r2_y * vp["year"], r2_w * vp["remainder"]))
As a share of total variance: between provinces 0.080 | between years 0.240 | within 0.076
cat("\nProvince means from the 217-observation sample\n")

Province means from the 217-observation sample
shapley <- function(d) sapply(core, function(v) { others <- setdiff(core, v); s <- 0
  for (k in 0:3) for (S in if (k == 0) list(character(0)) else combn(others, k, simplify = FALSE)) {
    w <- factorial(length(S)) * factorial(3 - length(S)) / factorial(4)
    s <- s + w * (summary(lm(reformulate(c(S, v), "never_rate"), d))$r.squared -
                  if (length(S)) summary(lm(reformulate(S, "never_rate"), d))$r.squared else 0) }
  s })
for (lab in c("all 31", "without Tibet")) {
  d <- if (lab == "all 31") pm217 else pm217[pm217$prov_en != "Tibet", ]
  cat("\n[", lab, "]\n")
  print(round(rbind(r = sapply(core, function(v) cor(d$never_rate, d[[v]])),
                    R2 = sapply(core, function(v) summary(lm(reformulate(v, "never_rate"), d))$r.squared),
                    Shapley = shapley(d),
                    VIF = sapply(core, function(v) 1 / (1 - summary(lm(reformulate(setdiff(core, v), v), d))$r.squared))), 3))
  cat("R2 of all four:", round(summary(lm(reformulate(core, "never_rate"), d))$r.squared, 3),
      "| correlation tertiary vs agricultural hukou:", round(cor(d$higher_edu, d$agri_share), 3), "\n")
}

[ all 31 ]
        young_share sex_ratio higher_edu agri_share
r             0.375     0.409     -0.221      0.164
R2            0.141     0.167      0.049      0.027
Shapley       0.179     0.139      0.048      0.041
VIF           2.052     1.425      3.388      3.664
R2 of all four: 0.408 | correlation tertiary vs agricultural hukou: -0.654 

[ without Tibet ]
        young_share sex_ratio higher_edu agri_share
r             0.471     0.318     -0.063      0.075
R2            0.221     0.101      0.004      0.006
Shapley       0.280     0.101      0.027      0.058
VIF           2.051     1.337      3.073      3.529
R2 of all four: 0.466 | correlation tertiary vs agricultural hukou: -0.637 

7.4 Cross-validation with age groups

reg18 <- merge(reg, P18[, c("year", "prov_en", paste0("s_", grp))], by = c("year", "prov_en"))
reg18 <- reg18[order(match(reg18$prov_en, unique(reg$prov_en))), ]
cv2 <- list("Year means only" = never_rate ~ factor(year),
            "Year + youth share" = never_rate ~ factor(year) + young_share,
            "Year + youth share + sex ratio" = never_rate ~ factor(year) + young_share + sex_ratio,
            "Year + four features" = never_rate ~ factor(year) + young_share + sex_ratio + higher_edu + agri_share,
            "Year + age groups" = never_rate ~ factor(year) + s_a15_19 + s_a20_24 + s_a25_29 + s_a30_39,
            "Year + age groups + sex ratio" = never_rate ~ factor(year) + s_a15_19 + s_a20_24 + s_a25_29 + s_a30_39 + sex_ratio)
po <- unlist(lapply(unique(reg18$prov_en), function(p) rep(p, sum(reg18$prov_en == p))))
e2 <- lapply(cv2, function(f) unlist(lapply(unique(reg18$prov_en), function(p) {
  tr <- reg18[reg18$prov_en != p, ]; te <- reg18[reg18$prov_en == p, ]; te$never_rate - predict(lm(f, tr), te) })))
b1 <- mean(e2[[1]]^2); b1n <- mean(e2[[1]][po != "Tibet"]^2)
print(round(t(sapply(e2, function(e) c(RMSE = sqrt(mean(e^2)), R2_oos = 1 - mean(e^2) / b1,
  RMSE_without_Tibet = sqrt(mean(e[po != "Tibet"]^2)), R2_oos_without_Tibet = 1 - mean(e[po != "Tibet"]^2) / b1n))), 3))
                                RMSE R2_oos RMSE_without_Tibet
Year means only                5.707  0.000              5.104
Year + youth share             5.164  0.181              4.444
Year + youth share + sex ratio 4.882  0.268              4.293
Year + four features           4.792  0.295              4.168
Year + age groups              3.888  0.536              3.178
Year + age groups + sex ratio  3.729  0.573              3.096
                               R2_oos_without_Tibet
Year means only                               0.000
Year + youth share                            0.242
Year + youth share + sex ratio                0.292
Year + four features                          0.333
Year + age groups                             0.612
Year + age groups + sex ratio                 0.632

7.5 Other characteristics after controlling for age groups

agevars <- paste0("p_", grp[-5])
for (i in seq_along(agevars)) { reg18[[agevars[i]]] <- 100 * reg18[[paste0("s_", grp[i])]] }
others <- c("sex_ratio", "higher_edu", "agri_share")
G18 <- length(unique(reg18$prov_en)); t18 <- qt(0.975, G18 - 1)
ci_rows <- function(m, keep, g) {
  X <- model.matrix(m); V <- cl_vcov(X, residuals(m), g, rep(1, nrow(X)))
  t(sapply(keep, function(v) { j <- which(colnames(X) == v); b <- coef(m)[j]; s <- sqrt(V[j, j])
    c(Estimate = unname(b), CI_low = unname(b - t18 * s), CI_high = unname(b + t18 * s), p = unname(2 * pt(-abs(b / s), G18 - 1))) }))
}
m3a <- lm(reformulate(c(agevars, others, "factor(year)", "factor(prov_en)"), "never_rate"), data = reg18)
cat("Model 3 with age groups, N =", nrow(reg18), "| within R2 =",
    round(summary(lm(residuals(lm(never_rate ~ factor(year) + factor(prov_en), reg18)) ~
      sapply(c(agevars, others), function(v) residuals(lm(reformulate(c("factor(year)", "factor(prov_en)"), v), reg18)))))$r.squared, 3), "\n")
Model 3 with age groups, N = 217 | within R2 = 0.719 
print(round(ci_rows(m3a, c(agevars, others), reg18$prov_en), 3))
           Estimate CI_low CI_high     p
p_a15_19      1.292  0.818   1.766 0.000
p_a20_24      1.126  0.802   1.450 0.000
p_a25_29      0.190 -0.250   0.630 0.384
p_a30_39      0.224 -0.088   0.536 0.153
sex_ratio     0.073  0.017   0.129 0.012
higher_edu   -0.029 -0.274   0.216 0.812
agri_share   -0.034 -0.184   0.116 0.648
mu <- reg18
for (v in c(agevars, others)) { mu[[paste0(v, "_b")]] <- ave(mu[[v]], mu$prov_en); mu[[paste0(v, "_w")]] <- mu[[v]] - mu[[paste0(v, "_b")]] }
mma <- lm(reformulate(c(paste0(c(agevars, others), "_w"), paste0(c(agevars, others), "_b"), "factor(year)"), "never_rate"), data = mu)
cat("\nMundlak with age groups: within and between coefficients\n")

Mundlak with age groups: within and between coefficients
print(round(rbind(within = ci_rows(mma, paste0(others, "_w"), mu$prov_en), between = ci_rows(mma, paste0(others, "_b"), mu$prov_en)), 3))
             Estimate CI_low CI_high     p
sex_ratio_w     0.073  0.021   0.126 0.008
higher_edu_w   -0.029 -0.259   0.202 0.801
agri_share_w   -0.034 -0.175   0.107 0.627
sex_ratio_b     0.099 -0.004   0.201 0.058
higher_edu_b   -0.212 -0.688   0.265 0.372
agri_share_b   -0.093 -0.323   0.138 0.418
pc_age <- function(d, v) cor(residuals(lm(reformulate(agevars, "never_rate"), d)), residuals(lm(reformulate(agevars, v), d)))
pm18 <- aggregate(reg18[, c("never_rate", agevars, others)], by = list(prov_en = reg18$prov_en), FUN = mean)
cat("\nPartial correlations controlling for the age-group shares\n")

Partial correlations controlling for the age-group shares
print(round(rbind(pooled_217 = sapply(others, function(v) pc_age(reg18, v)),
                  province_means = sapply(others, function(v) pc_age(pm18, v)),
                  province_means_without_Tibet = sapply(others, function(v) pc_age(pm18[pm18$prov_en != "Tibet", ], v))), 3))
                             sex_ratio higher_edu agri_share
pooled_217                       0.313      0.039     -0.053
province_means                   0.332     -0.222      0.100
province_means_without_Tibet     0.190      0.177     -0.271
cat("Between provinces: R2 age groups =", round(summary(lm(reformulate(agevars, "never_rate"), pm18))$r.squared, 3),
    "| + sex ratio =", round(summary(lm(reformulate(c(agevars, "sex_ratio"), "never_rate"), pm18))$r.squared, 3),
    "| + three features =", round(summary(lm(reformulate(c(agevars, others), "never_rate"), pm18))$r.squared, 3), "\n")
Between provinces: R2 age groups = 0.703 | + sex ratio = 0.736 | + three features = 0.744 
cat("Without Tibet: R2 age groups =", round(summary(lm(reformulate(agevars, "never_rate"), pm18[pm18$prov_en != "Tibet", ]))$r.squared, 3), "\n")
Without Tibet: R2 age groups = 0.781 

7.6 Monotone Goodman regression

Cum <- t(apply(Xs, 1, cumsum)); colnames(Cum) <- paste0("C", 1:5)
fit_mono <- function(C, y) {        # r_a = sum_{k >= a} d_k, d_k >= 0, sum(d) <= 1
  f <- function(d) sum((y - C %*% d)^2); g <- function(d) -2 * drop(t(C) %*% (y - C %*% d))
  d <- constrOptim(rep(0.05, 5), f, g, ui = rbind(diag(5), -rep(1, 5)), ci = c(rep(0, 5), -1) - 1e-10)$par
  rev(cumsum(rev(d)))
}
fit_box  <- function(X, y) bls(X, y)
fit_free <- function(X, y) unname(coef(lm(y ~ 0 + X)))
rates <- rbind(unconstrained = fit_free(Xs, ynr), bounded_0_1 = fit_box(Xs, ynr), monotone = fit_mono(Cum, ynr))
colnames(rates) <- grp; print(round(rates, 3))
              a15_19 a20_24 a25_29 a30_39 a40_59
unconstrained  0.269  1.469 -0.208 -0.062  0.179
bounded_0_1    0.725  1.000  0.000  0.000  0.098
monotone       0.998  0.998  0.020  0.020  0.020
rates_y <- t(sapply(sort(unique(P18$year)), function(y) { k <- P18$year == y; fit_mono(Cum[k, ], ynr[k]) }))
dimnames(rates_y) <- list(sort(unique(P18$year)), grp)
cat("Monotone, by year:\n"); print(round(rates_y, 3))
Monotone, by year:
     a15_19 a20_24 a25_29 a30_39 a40_59
2011  0.999  0.988  0.000  0.000  0.000
2012  1.000  1.000  0.014  0.014  0.014
2013  1.000  1.000  0.030  0.030  0.030
2014  1.000  1.000  0.045  0.045  0.045
2015  1.000  1.000  0.017  0.017  0.017
2016  0.695  0.661  0.068  0.068  0.068
2017  0.768  0.768  0.050  0.050  0.050
2018  1.000  1.000  0.134  0.017  0.017
fit_stats <- function(fun, Xmat, yearly) {
  pred <- numeric(nrow(P18))
  if (yearly) { for (y in unique(P18$year)) { k <- P18$year == y; pred[k] <- Xs[k, ] %*% fun(Xmat[k, ], ynr[k]) } }
  else pred <- drop(Xs %*% fun(Xmat, ynr))
  pm_ <- aggregate(cbind(a = ynr, p = pred) ~ P18$prov_en, FUN = mean)
  cvp <- numeric(nrow(P18))
  for (pr in unique(P18$prov_en)) {
    te <- P18$prov_en == pr
    if (yearly) { for (y in unique(P18$year)) { k <- P18$year == y
        tr <- k & !te; tt <- k & te; cvp[tt] <- Xs[tt, , drop = FALSE] %*% fun(Xmat[tr, ], ynr[tr]) } }
    else cvp[te] <- Xs[te, , drop = FALSE] %*% fun(Xmat[!te, ], ynr[!te])
  }
  c(R2_all = r2v(ynr, pred), R2_province_means = r2v(pm_$a, pm_$p),
    RMSE_LOPO_pp = 100 * sqrt(mean((ynr - cvp)^2)))
}
fs <- rbind(
  unconstrained_pooled = fit_stats(fit_free, Xs, FALSE), bounded_pooled = fit_stats(fit_box, Xs, FALSE), monotone_pooled = fit_stats(fit_mono, Cum, FALSE),
  unconstrained_yearly = fit_stats(fit_free, Xs, TRUE),  bounded_yearly = fit_stats(fit_box, Xs, TRUE),  monotone_yearly = fit_stats(fit_mono, Cum, TRUE))
print(round(fs, 3))
                     R2_all R2_province_means RMSE_LOPO_pp
unconstrained_pooled  0.648             0.602        4.209
bounded_pooled        0.602             0.492        4.335
monotone_pooled       0.577             0.452        4.466
unconstrained_yearly  0.723             0.662        4.127
bounded_yearly        0.648             0.511        4.237
monotone_yearly       0.623             0.460        4.261
cat("LOPO RMSE of 'Year means only' =", round(sqrt(mean(e2[[1]]^2)), 3), "| 'Year + age groups' =", round(sqrt(mean(e2[["Year + age groups"]]^2)), 3), "\n")
LOPO RMSE of 'Year means only' = 5.707 | 'Year + age groups' = 3.888 

7.7 Age composition within the youth group

P18$y2024 <- P18$a20_24 / (P18$a15_19 + P18$a20_24 + P18$a25_29)
P18$y2529 <- P18$a25_29 / (P18$a15_19 + P18$a20_24 + P18$a25_29)
dm2 <- function(v) residuals(lm(v ~ factor(year) + factor(prov_en), P18))
pmy <- aggregate(cbind(young_share, young1519, y2024, y2529, never_rate) ~ prov_en, P18, mean)
print(round(rbind(
  corr_with_youth_share = c(pooled = sapply(c("young1519", "y2024", "y2529"), function(v) cor(P18$young_share, P18[[v]])),
                            province_means = sapply(c("young1519", "y2024", "y2529"), function(v) cor(pmy$young_share, pmy[[v]])),
                            within = sapply(c("young1519", "y2024", "y2529"), function(v) cor(dm2(P18$young_share), dm2(P18[[v]])))),
  corr_with_never_rate = c(pooled = sapply(c("young1519", "y2024", "y2529"), function(v) cor(P18$never_rate, P18[[v]])),
                           province_means = sapply(c("young1519", "y2024", "y2529"), function(v) cor(pmy$never_rate, pmy[[v]])),
                           within = sapply(c("young1519", "y2024", "y2529"), function(v) cor(dm2(P18$never_rate), dm2(P18[[v]]))))), 3))
                      pooled.young1519 pooled.y2024 pooled.y2529
corr_with_youth_share            0.200        0.693       -0.591
corr_with_never_rate             0.317        0.761       -0.711
                      province_means.young1519 province_means.y2024
corr_with_youth_share                    0.106                0.465
corr_with_never_rate                     0.371                0.817
                      province_means.y2529 within.young1519 within.y2024
corr_with_youth_share               -0.361            0.169        0.515
corr_with_never_rate                -0.732            0.486        0.504
                      within.y2529
corr_with_youth_share       -0.478
corr_with_never_rate        -0.684
k15 <- P18$year <= 2015
cat(sprintf("2011-2015: mean never-married share %.3f | mean youth share %.3f | implied r(15-29) if all 30+ married %.3f\n",
            mean(P18$never_rate[k15]) / 100, mean(P18$young_share[k15]) / 100, mean(P18$never_rate[k15]) / mean(P18$young_share[k15])))
2011-2015: mean never-married share 0.207 | mean youth share 0.384 | implied r(15-29) if all 30+ married 0.540

7.8 Indirect standardisation with the 2010 census

Census counts: Tabulation on the 2010 Population Census of China, Table 5-3a (city), long form, both sexes.

census <- data.frame(
  age   = c("15-19", "20-24", "25-29", "30-34", "35-39", "40-44", "45-49", "50-54", "55-59"),
  total = c(2728757, 4276600, 3484422, 3299594, 3713982, 3541583, 3058752, 2311229, 2158466),
  never = c(2712072, 3633319, 1285204, 341651, 150538, 75994, 43230, 24912, 18518))
cen_grp <- list(a15_19 = 1, a20_24 = 2, a25_29 = 3, a30_39 = 4:5, a40_59 = 6:9)
R_cen <- sapply(cen_grp, function(i) sum(census$never[i]) / sum(census$total[i]))
cat("Never-married rate by age group (city)\n"); print(round(R_cen, 3))
Never-married rate by age group (city)
a15_19 a20_24 a25_29 a30_39 a40_59 
 0.994  0.850  0.369  0.070  0.015 
cat("Composition bound for each age-group coefficient:\n"); print(round(R_cen[1:4] - R_cen[5], 3))
Composition bound for each age-group coefficient:
a15_19 a20_24 a25_29 a30_39 
 0.979  0.835  0.354  0.055 
P18$exp_census <- 100 * drop(Xs %*% R_cen)
cat(sprintf("Mean actual %.2f | mean census-expected %.2f\n", mean(P18$never_rate), mean(P18$exp_census)))
Mean actual 18.55 | mean census-expected 24.65
pmc <- aggregate(cbind(never_rate, exp_census) ~ prov_en, P18, mean)
cat(sprintf("Correlation actual vs expected: all %.3f | province means %.3f (R2 %.3f) | within %.3f\n",
            cor(P18$never_rate, P18$exp_census), cor(pmc$never_rate, pmc$exp_census), cor(pmc$never_rate, pmc$exp_census)^2,
            cor(dm2(P18$never_rate), dm2(P18$exp_census))))
Correlation actual vs expected: all 0.703 | province means 0.545 (R2 0.297) | within 0.798
cat(sprintf("Without Tibet, province means: R2 %.3f\n", with(pmc[pmc$prov_en != "Tibet", ], cor(never_rate, exp_census)^2)))
Without Tibet, province means: R2 0.400
rc <- merge(reg, P18[, c("year", "prov_en", "exp_census")], by = c("year", "prov_en"))
gc <- rc$prov_en; Gc <- length(unique(gc)); tc <- qt(0.975, Gc - 1)
slope_ci <- function(m, v) { X <- model.matrix(m); V <- cl_vcov(X, residuals(m), gc, rep(1, nrow(X))); j <- which(colnames(X) == v)
  b <- coef(m)[j]; s <- sqrt(V[j, j]); c(slope = unname(b), CI_low = unname(b - tc * s), CI_high = unname(b + tc * s)) }
rc_b <- aggregate(cbind(never_rate, exp_census) ~ prov_en, rc, mean)
cat("Slope of actual on census-expected (N = 217):\n")
Slope of actual on census-expected (N = 217):
print(round(rbind(two_way_FE = slope_ci(lm(never_rate ~ exp_census + factor(year) + factor(prov_en), rc), "exp_census"),
                  year_FE = slope_ci(lm(never_rate ~ exp_census + factor(year), rc), "exp_census"),
                  between_31_means = c(slope = unname(coef(lm(never_rate ~ exp_census, rc_b))[2]),
                                       confint(lm(never_rate ~ exp_census, rc_b))[2, ]),
                  two_way_FE_plus_sex_ratio = slope_ci(lm(never_rate ~ exp_census + sex_ratio + factor(year) + factor(prov_en), rc), "exp_census")), 3))
                          slope CI_low CI_high
two_way_FE                1.146  0.917   1.375
year_FE                   1.052  0.707   1.397
between_31_means          1.003  0.389   1.618
two_way_FE_plus_sex_ratio 1.209  1.001   1.416
cat("Sex ratio controlling for the census-expected share (two-way FE):\n")
Sex ratio controlling for the census-expected share (two-way FE):
print(round(slope_ci(lm(never_rate ~ exp_census + sex_ratio + factor(year) + factor(prov_en), rc), "sex_ratio"), 3))
  slope  CI_low CI_high 
  0.078   0.021   0.134 
pmc$gap <- round(pmc$never_rate - pmc$exp_census - mean(pmc$never_rate - pmc$exp_census), 1); pmc <- pmc[order(pmc$gap), ]
cat("Actual minus census-expected, centred (province means), lowest 5:\n"); print(head(pmc[, c("prov_en", "gap")], 5))
Actual minus census-expected, centred (province means), lowest 5:
    prov_en  gap
1     Anhui -8.2
13    Hubei -5.8
23 Shandong -5.7
20  Ningxia -5.0
16  Jiangsu -4.5
cat("Highest 5:\n"); print(tail(pmc[, c("prov_en", "gap")], 5))
Highest 5:
    prov_en  gap
10    Hebei  3.7
18    Jilin  4.0
12    Henan  4.7
19 Liaoning  7.7
28    Tibet 14.5
cat("Spearman correlation of province means (217 sample):\n")
Spearman correlation of province means (217 sample):
print(round(sapply(core, function(v) cor(pm217$never_rate, pm217[[v]], method = "spearman")), 3))
young_share   sex_ratio  higher_edu  agri_share 
      0.430       0.390      -0.161       0.204 
fa217 <- lm(reformulate(agevars, "never_rate"), data = pm18)
cat(sprintf("Between provinces, four age-group shares: R2 = %.3f, adjusted R2 = %.3f\n",
            summary(fa217)$r.squared, summary(fa217)$adj.r.squared))
Between provinces, four age-group shares: R2 = 0.703, adjusted R2 = 0.657
cat(sprintf("Five age groups, share of total variance via between provinces: %.3f\n", summary(fa217)$r.squared * vp["province"]))
Five age groups, share of total variance via between provinces: 0.401
wR2 <- function(xs) summary(lm(residuals(lm(never_rate ~ factor(year) + factor(prov_en), reg18)) ~
  sapply(xs, function(v) residuals(lm(reformulate(c("factor(year)", "factor(prov_en)"), v), reg18)))))$r.squared
cat(sprintf("Within provinces R2: age groups only %.3f | age groups + sex ratio %.3f\n", wR2(agevars), wR2(c(agevars, "sex_ratio"))))
Within provinces R2: age groups only 0.677 | age groups + sex ratio 0.719
cat(sprintf("Five age groups, share of total variance via within provinces: %.3f\n", wR2(agevars) * vp["remainder"]))
Five age groups, share of total variance via within provinces: 0.114
cat("MAE of the cross-validation specifications:\n")
MAE of the cross-validation specifications:
print(round(sapply(e2, function(e) mean(abs(e))), 3))
               Year means only             Year + youth share 
                         4.154                          3.796 
Year + youth share + sex ratio           Year + four features 
                         3.754                          3.646 
             Year + age groups  Year + age groups + sex ratio 
                         2.816                          2.735 

7.9 Four census standards

Tables 5-3 (national), 5-3a (city), 5-3b (town) and 5-3c (rural).

cen <- list(
  national = list(total = c(9190962, 11604416, 9278248, 8999299, 11099483, 11881111, 10159832, 7658776, 8011414),
                  never = c(9069370, 8698138, 2682763, 811360, 458064, 293283, 182178, 136332, 147629)),
  city     = list(total = census$total, never = census$never),
  town     = list(total = c(2021480, 2067283, 1754241, 1835721, 2315452, 2418522, 1990457, 1409108, 1434409),
                  never = c(2002917, 1511207, 429858, 116887, 62132, 38454, 22856, 16602, 18349)),
  rural    = list(total = c(4440725, 5260533, 4039585, 3863984, 5070049, 5921006, 5110623, 3938439, 4418539),
                  never = c(4354381, 3553612, 967701, 352822, 245394, 178835, 116092, 94818, 110762)))
chk <- with(cen, c(total = all(national$total == city$total + town$total + rural$total),
                   never = all(national$never == city$never + town$never + rural$never)))
cat("Check: national = city + town + rural:", chk, "\n")
Check: national = city + town + rural: TRUE TRUE 
rate_of <- function(x) sapply(cen_grp, function(i) sum(x$never[i]) / sum(x$total[i]))
R_all <- t(sapply(cen, rate_of))
print(round(R_all, 3))
         a15_19 a20_24 a25_29 a30_39 a40_59
national  0.987  0.750  0.289  0.063  0.020
city      0.994  0.850  0.369  0.070  0.015
town      0.991  0.731  0.245  0.043  0.013
rural     0.981  0.676  0.240  0.067  0.026
lopo_r2 <- function(f, d) {
  e <- unlist(lapply(unique(d$prov_en), function(p) { tr <- d[d$prov_en != p, ]; te <- d[d$prov_en == p, ]
    te$never_rate - predict(lm(f, tr), te) }))
  e0 <- unlist(lapply(unique(d$prov_en), function(p) { tr <- d[d$prov_en != p, ]; te <- d[d$prov_en == p, ]
    te$never_rate - predict(lm(never_rate ~ factor(year), tr), te) }))
  c(R2_oos = 1 - mean(e^2) / mean(e0^2), RMSE = sqrt(mean(e^2)))
}
std_tab <- t(sapply(names(cen), function(nm) {
  d <- merge(reg, P18[, c("year", "prov_en", paste0("s_", grp))], by = c("year", "prov_en"))
  d$expv <- 100 * drop(as.matrix(d[, paste0("s_", grp)]) %*% R_all[nm, ])
  gc <- d$prov_en
  sl <- function(m) { X <- model.matrix(m); V <- cl_vcov(X, residuals(m), gc, rep(1, nrow(X))); j <- which(colnames(X) == "expv")
    b <- coef(m)[j]; s <- sqrt(V[j, j]); c(unname(b), unname(b - tc * s), unname(b + tc * s)) }
  w  <- sl(lm(never_rate ~ expv + factor(year) + factor(prov_en), d))
  ws <- sl(lm(never_rate ~ expv + sex_ratio + factor(year) + factor(prov_en), d))
  db <- aggregate(cbind(never_rate, expv) ~ prov_en, d, mean); mb <- lm(never_rate ~ expv, db)
  c(mean_expected = mean(d$expv), within_slope = w[1], within_low = w[2], within_high = w[3],
    within_slope_sr = ws[1], within_sr_low = ws[2], within_sr_high = ws[3],
    between_slope = unname(coef(mb)[2]), between_low = confint(mb)[2, 1], between_high = confint(mb)[2, 2],
    R2_between = cor(db$never_rate, db$expv)^2,
    R2_between_noTibet = with(db[db$prov_en != "Tibet", ], cor(never_rate, expv)^2),
    R2_within = cor(residuals(lm(never_rate ~ factor(year) + factor(prov_en), d)), residuals(lm(expv ~ factor(year) + factor(prov_en), d)))^2,
    lopo_offset = lopo_r2(never_rate ~ factor(year) + offset(expv), d),
    lopo_slope = lopo_r2(never_rate ~ factor(year) + expv, d))
}))
cat("\nlopo_offset: slope fixed at 1; lopo_slope: one slope estimated\n")

lopo_offset: slope fixed at 1; lopo_slope: one slope estimated
print(round(t(std_tab), 3))
                   national   city   town  rural
mean_expected        21.713 24.413 19.835 20.231
within_slope          1.264  1.146  1.262  1.370
within_low            1.025  0.917  1.030  1.120
within_high           1.503  1.375  1.494  1.621
within_slope_sr       1.324  1.209  1.320  1.428
within_sr_low         1.105  1.001  1.105  1.195
within_sr_high        1.543  1.416  1.534  1.660
between_slope         1.158  1.003  1.210  1.279
between_low           0.489  0.389  0.548  0.559
between_high          1.826  1.618  1.872  1.999
R2_between            0.302  0.278  0.325  0.313
R2_between_noTibet    0.423  0.394  0.448  0.437
R2_within             0.651  0.635  0.656  0.661
lopo_offset.R2_oos    0.371  0.357  0.387  0.370
lopo_offset.RMSE      4.526  4.577  4.469  4.531
lopo_slope.R2_oos     0.361  0.337  0.381  0.372
lopo_slope.RMSE       4.561  4.647  4.490  4.522
cat("Reference: LOPO R2_oos of 'Year + youth share' =", round(1 - mean(e2[["Year + youth share"]]^2) / mean(e2[[1]]^2), 3),
    "| 'Year + age groups' =", round(1 - mean(e2[["Year + age groups"]]^2) / mean(e2[[1]]^2), 3), "\n")
Reference: LOPO R2_oos of 'Year + youth share' = 0.181 | 'Year + age groups' = 0.536 

7.10 What age groups cannot explain

cat(sprintf("Unexplained between-province variance: %.3f | unexplained out-of-sample variance: %.3f\n",
            1 - summary(fa217)$r.squared, 1 - (1 - mean(e2[["Year + age groups"]]^2) / mean(e2[[1]]^2))))
Unexplained between-province variance: 0.297 | unexplained out-of-sample variance: 0.464
ea <- e2[["Year + age groups"]]
pres <- data.frame(prov_en = unique(po), mean_error = tapply(ea, po, mean)[unique(po)],
                   same_sign_years = tapply(ea, po, function(e) mean(sign(e) == sign(mean(e))))[unique(po)])
pres <- pres[order(pres$mean_error), ]; pres$mean_error <- round(pres$mean_error, 1); pres$same_sign_years <- round(pres$same_sign_years, 2)
cat("Held-out prediction error (actual - predicted), lowest 5\n"); print(head(pres, 5), row.names = FALSE)
Held-out prediction error (actual - predicted), lowest 5
  prov_en mean_error same_sign_years
    Hubei       -4.3            1.00
 Zhejiang       -4.0            1.00
  Jiangsu       -3.8            0.86
  Qinghai       -3.8            1.00
    Anhui       -3.6            1.00
cat("Highest 5\n"); print(tail(pres, 5), row.names = FALSE)
Highest 5
  prov_en mean_error same_sign_years
    Hebei        3.1            1.00
  Guangxi        4.0            1.00
    Henan        4.2            1.00
 Liaoning        4.8            0.86
    Tibet       12.0            1.00
cat("Provinces whose 7 yearly errors all have the same sign:", sum(pres$same_sign_years == 1), "of 31\n")
Provinces whose 7 yearly errors all have the same sign: 14 of 31

8. Province types

Input: province means of within-year z-scores of the four characteristics (the never-married share is excluded). The number of types is the K in 2–5 with the largest average silhouette width.

prov_z <- aggregate(zpanel[vars], by = list(prov_en = zpanel$prov_en), FUN = mean, na.rm = TRUE)
stopifnot(identical(prov_z$prov_en, prov_avg$prov_en))
z4 <- scale(prov_z[core]); z5 <- scale(prov_z[vars])
relabel <- function(cl) { o <- as.integer(names(sort(table(cl), decreasing = TRUE))); match(cl, o) }  # 1 = largest
km_run <- function(z, k, seed = 42) { set.seed(seed); relabel(kmeans(z, centers = k, nstart = 100)$cluster) }

sil_width <- function(cl) mean(silhouette(cl, dist(z4))[, "sil_width"])
ksel <- t(sapply(1:6, function(k) {
  set.seed(42); km <- kmeans(z4, centers = k, nstart = 100)
  c(k = k, within_SS = km$tot.withinss, explained = 1 - km$tot.withinss / km$totss,
    silhouette = if (k == 1) NA else sil_width(km$cluster))
}))
print(round(ksel, 3))
     k within_SS explained silhouette
[1,] 1   120.000     0.000         NA
[2,] 2    75.780     0.368      0.304
[3,] 3    53.877     0.551      0.308
[4,] 4    40.045     0.666      0.315
[5,] 5    33.111     0.724      0.295
[6,] 6    28.167     0.765      0.295
K_main <- (2:5)[which.max(ksel[2:5, "silhouette"])]
cat("K =", K_main, "\n")
K = 4 
par(mfrow = c(1, 2), mar = c(4.5, 4.5, 2, 1))
plot(ksel[, "k"], ksel[, "within_SS"], type = "b", pch = 19, xlab = "Number of clusters (k)",
     ylab = "Within-cluster sum of squares", main = "Elbow plot")
plot(ksel[-1, "k"], ksel[-1, "silhouette"], type = "b", pch = 19, xlab = "Number of clusters (k)",
     ylab = "Average silhouette width", main = "Silhouette")
Figure 11. Choosing the number of clusters

Figure 11. Choosing the number of clusters

ari <- function(a, b) {
  tab <- table(a, b); s <- sum(choose(tab, 2)); sa <- sum(choose(rowSums(tab), 2))
  sb <- sum(choose(colSums(tab), 2)); e <- sa * sb / choose(length(a), 2)
  (s - e) / ((sa + sb) / 2 - e)
}
describe_partition <- function(main, k, label) {
  cat("\n", label, " (K = ", k, ")\n", sep = "")
  for (t in 1:k) cat("Type", t, "(", sum(main == t), "):", prov_avg$prov_en[main == t], "\n")
  print(round(aggregate(prov_avg[vars], by = list(type = main), FUN = mean), 1))
  alts <- list(km5 = km_run(z5, k), ward = relabel(cutree(hclust(dist(z4), method = "ward.D2"), k = k)),
               pam = relabel(pam(z4, k = k)$clustering))
  cat("Adjusted Rand index: K-means with outcome =", round(ari(main, alts$km5), 3),
      "| Ward =", round(ari(main, alts$ward), 3), "| PAM =", round(ari(main, alts$pam), 3), "\n")
  for (alt in names(alts)) {
    tab <- table(main, alts[[alt]]); best <- apply(tab, 2, which.max)
    moved <- prov_avg$prov_en[best[as.character(alts[[alt]])] != main]
    cat("Placed differently by", alt, ":", if (length(moved)) moved else "none", "\n")
  }
  # Bootstrap stability (Jaccard; Hennig 2007)
  set.seed(2026); Bb <- 1000; jac <- matrix(NA, Bb, k)
  for (b in seq_len(Bb)) {
    idx <- sample(31, 31, replace = TRUE); u <- unique(idx)
    cb <- kmeans(z4[idx, ], centers = k, nstart = 20)$cluster
    lab <- cb[match(u, idx)]
    for (t in 1:k) {
      A <- u[main[u] == t]; if (!length(A)) next
      jac[b, t] <- max(sapply(unique(lab), function(h) { Bset <- u[lab == h]
        length(intersect(A, Bset)) / length(union(A, Bset)) }))
    }
  }
  cat("Bootstrap Jaccard by type:\n")
  print(round(rbind(mean = colMeans(jac, na.rm = TRUE), share_gt_0.75 = colMeans(jac > 0.75, na.rm = TRUE)), 2))
  invisible(alts)
}
prov_avg$type  <- km_run(z4, K_main)
prov_avg$type3 <- km_run(z4, 3)
alts_main <- describe_partition(prov_avg$type, K_main, "Main partition")

Main partition (K = 4)
Type 1 ( 11 ): Anhui Gansu Guangdong Henan Hubei Hunan Jiangsu Jiangxi Ningxia Shaanxi Shandong 
Type 2 ( 10 ): Guangxi Hainan Heilongjiang Inner Mongolia Jilin Liaoning Shanxi Sichuan Tianjin Xinjiang 
Type 3 ( 7 ): Fujian Guizhou Hebei Qinghai Tibet Yunnan Zhejiang 
Type 4 ( 3 ): Beijing Chongqing Shanghai 
  type never_rate young_share sex_ratio higher_edu agri_share
1    1       17.5        37.5     107.5       11.7       87.2
2    2       17.1        31.8     113.6       11.7       80.1
3    3       22.4        36.6     123.8        7.2       89.3
4    4       18.1        35.3     102.5       22.8       71.9
Adjusted Rand index: K-means with outcome = 0.822 | Ward = 1 | PAM = 0.891 
Placed differently by km5 : Henan Jiangxi 
Placed differently by ward : none 
Placed differently by pam : Guangxi 
Bootstrap Jaccard by type:
              [,1] [,2] [,3] [,4]
mean          0.68 0.80 0.77 0.87
share_gt_0.75 0.41 0.59 0.55 0.79
prov_avg$km5 <- alts_main$km5
alts_k3 <- describe_partition(prov_avg$type3, 3, "Three types")

Three types (K = 3)
Type 1 ( 15 ): Fujian Gansu Guangdong Guizhou Hebei Henan Hunan Jiangsu Jiangxi Ningxia Qinghai Shaanxi Tibet Yunnan Zhejiang 
Type 2 ( 13 ): Anhui Guangxi Hainan Heilongjiang Hubei Inner Mongolia Jilin Liaoning Shandong Shanxi Sichuan Tianjin Xinjiang 
Type 3 ( 3 ): Beijing Chongqing Shanghai 
  type never_rate young_share sex_ratio higher_edu agri_share
1    1       21.2        37.6     116.5        9.5       88.2
2    2       15.5        32.5     110.6       11.8       81.7
3    3       18.1        35.3     102.5       22.8       71.9
Adjusted Rand index: K-means with outcome = 0.879 | Ward = 0.668 | PAM = 0.879 
Placed differently by km5 : Ningxia 
Placed differently by ward : Anhui Hubei Shandong 
Placed differently by pam : Anhui 
Bootstrap Jaccard by type:
              [,1] [,2] [,3]
mean          0.78 0.79 0.76
share_gt_0.75 0.60 0.59 0.67
ggplot(prov_avg, aes(young_share, never_rate, colour = factor(type), shape = factor(type), label = prov_en)) +
  geom_point(size = 2.6) +
  geom_text_repel(size = 2.7, colour = "grey25", seed = 1, max.overlaps = Inf, min.segment.length = 0) +
  scale_colour_manual(values = c("tomato", "steelblue", "seagreen", "darkorange", "purple")[1:K_main], labels = paste("Type", 1:K_main)) +
  scale_shape_manual(values = c(16, 15, 17, 18, 8)[1:K_main], labels = paste("Type", 1:K_main)) +
  labs(x = "Youth share, ages 15-29 (%), province mean", y = "Never-married share (%), province mean",
       colour = NULL, shape = NULL) +
  theme_bw(base_size = 10) + theme(legend.position = "bottom", panel.grid.minor = element_blank())
Figure 12. Province types based on migrant characteristics

Figure 12. Province types based on migrant characteristics

# eta-squared with permutation tests (10,000 relabellings, same group sizes)
eta2 <- function(y, g) { m <- ave(y, g); sum((m - mean(y))^2) / sum((y - mean(y))^2) }
perm_test <- function(g, R = 10000) {
  y <- prov_avg$never_rate; obs <- eta2(y, g)
  null <- replicate(R, eta2(y, sample(g)))
  c(eta2 = obs, p_perm = mean(null >= obs), null_95 = unname(quantile(null, 0.95)),
    max_gap = diff(range(tapply(y, g, mean))))
}
prov_avg$region <- ifelse(prov_avg$prov_en %in% east, 1, ifelse(prov_avg$prov_en %in% central, 2, 3))
yr_rank <- rank(-prov_avg$young_share, ties.method = "first")
prov_avg$youth3 <- cut(yr_rank, c(0, 10, 21, 31), labels = FALSE)
sz <- cumsum(table(prov_avg$type)); prov_avg$youth_sz <- cut(yr_rank, c(0, sz), labels = FALSE)
for (k in 2:5) prov_avg[[paste0("k", k)]] <- km_run(z4, k)
set.seed(2026)
print(round(t(sapply(c(types_main = "type", types_k2 = "k2", types_k3 = "k3", types_k5 = "k5",
                       types_main_with_outcome = "km5", regions = "region",
                       youth_tertiles = "youth3", youth_same_sizes_as_main = "youth_sz"),
                     function(v) perm_test(prov_avg[[v]]))), 3))
                          eta2 p_perm null_95 max_gap
types_main               0.164  0.178   0.250   5.235
types_k2                 0.087  0.101   0.124   3.032
types_k3                 0.279  0.009   0.193   5.702
types_k5                 0.341  0.027   0.304   7.934
types_main_with_outcome  0.354  0.009   0.249   7.519
regions                  0.048  0.503   0.191   2.806
youth_tertiles           0.079  0.314   0.187   3.452
youth_same_sizes_as_main 0.102  0.402   0.246   5.224
# Permutation test including the choice of K
parts <- as.matrix(prov_avg[, paste0("k", 2:5)])
yprov <- prov_avg$never_rate
obs_k <- apply(parts, 2, function(g) eta2(yprov, g))
set.seed(2026); R <- 10000
null_max <- replicate(R, { yp <- sample(yprov); max(apply(parts, 2, function(g) eta2(yp, g))) })
cat("\nIncluding the choice of K:\n")

Including the choice of K:
print(round(obs_k, 3))
   k2    k3    k4    k5 
0.087 0.279 0.164 0.341 
cat("Largest eta-squared:", round(max(obs_k), 3), "( K =", which.max(obs_k) + 1, ")",
    "| 95th percentile of null maximum:", round(quantile(null_max, 0.95), 3),
    "| p =", round(mean(null_max >= max(obs_k)), 3), "\n")
Largest eta-squared: 0.341 ( K = 5 ) | 95th percentile of null maximum: 0.304 | p = 0.026 
region_means <- aggregate(never_rate ~ region, data = panel, FUN = mean)
region_means$never_rate <- round(region_means$never_rate, 1)
cat("\nNever-married share by region:\n"); print(region_means)

Never-married share by region:
   region never_rate
1 Central       17.0
2    East       18.6
3    West       19.8
res <- panel[, c("prov_en", "young_share", "never_rate")]
res$resid <- residuals(lm(never_rate ~ young_share, res))
pr <- aggregate(resid ~ prov_en, res, mean); pr <- pr[order(pr$resid), ]; pr$resid <- round(pr$resid, 1)
cat("\nBelow age-predicted level (top 5):\n"); print(head(pr, 5))

Below age-predicted level (top 5):
    prov_en resid
1     Anhui  -9.7
23 Shandong  -7.5
13    Hubei  -6.8
20  Ningxia  -4.9
16  Jiangsu  -4.9
cat("Above age-predicted level (top 5):\n"); print(tail(pr, 5))
Above age-predicted level (top 5):
    prov_en resid
18    Jilin   3.8
10    Hebei   3.9
12    Henan   5.8
19 Liaoning   8.2
28    Tibet  15.1

9. Leave-one-province-out cross-validation

reg$region3 <- factor(ifelse(reg$prov_en %in% east, "East", ifelse(reg$prov_en %in% central, "Central", "West")))
cv_specs <- list("Year means only" = never_rate ~ factor(year),
                 "Year + region" = never_rate ~ factor(year) + region3,
                 "Year + youth share" = never_rate ~ factor(year) + young_share,
                 "Year + four features" = never_rate ~ factor(year) + young_share + sex_ratio + higher_edu + agri_share,
                 "Year + four features + region" = never_rate ~ factor(year) + young_share + sex_ratio + higher_edu + agri_share + region3)
lopo <- function(fit_fun) unlist(lapply(unique(reg$prov_en), function(p) {
  tr <- reg[reg$prov_en != p, ]; te <- reg[reg$prov_en == p, ]; te$never_rate - fit_fun(tr, te) }))
errs <- lapply(cv_specs, function(f) lopo(function(tr, te) predict(lm(f, data = tr), newdata = te)))
errs[["Regression tree (four features + year)"]] <- lopo(function(tr, te)
  predict(rpart(never_rate ~ young_share + sex_ratio + higher_edu + agri_share + year, data = tr,
                control = rpart.control(minsplit = 20, cp = 0.01)), newdata = te))
base <- sqrt(mean(errs[[1]]^2))
cv_tab <- t(sapply(errs, function(e) c(RMSE = sqrt(mean(e^2)), MAE = mean(abs(e)), R2_oos = 1 - mean(e^2) / base^2)))
print(round(cv_tab, 3))
                                        RMSE   MAE R2_oos
Year means only                        5.707 4.154  0.000
Year + region                          5.936 4.386 -0.082
Year + youth share                     5.164 3.796  0.181
Year + four features                   4.792 3.646  0.295
Year + four features + region          5.044 3.836  0.219
Regression tree (four features + year) 5.359 4.172  0.118
prov_order <- unlist(lapply(unique(reg$prov_en), function(p) rep(p, sum(reg$prov_en == p))))
e4 <- errs[["Year + four features"]]
pe <- sort(tapply(e4, prov_order, function(e) sqrt(mean(e^2))), decreasing = TRUE)
cat("\nRMSE by held-out province, 'Year + four features', top 5:\n"); print(round(head(pe, 5), 2))

RMSE by held-out province, 'Year + four features', top 5:
   Tibet    Henan    Anhui Liaoning   Hainan 
   13.81     9.94     7.27     6.49     6.45 
keep <- prov_order != "Tibet"
base_nt <- sqrt(mean(errs[[1]][keep]^2))
print(round(t(sapply(errs, function(e) c(RMSE_without_Tibet = sqrt(mean(e[keep]^2)),
  MedianAE = median(abs(e)), R2_oos_without_Tibet = 1 - mean(e[keep]^2) / base_nt^2))), 3))
                                       RMSE_without_Tibet MedianAE
Year means only                                     5.104    3.117
Year + region                                       5.389    3.394
Year + youth share                                  4.444    2.736
Year + four features                                4.168    2.822
Year + four features + region                       4.455    3.088
Regression tree (four features + year)              4.812    3.484
                                       R2_oos_without_Tibet
Year means only                                       0.000
Year + region                                        -0.115
Year + youth share                                    0.242
Year + four features                                  0.333
Year + four features + region                         0.238
Regression tree (four features + year)                0.111
par(mar = c(4.5, 15, 1, 1))
o <- order(cv_tab[, "RMSE"], decreasing = TRUE)
bp <- barplot(cv_tab[o, "RMSE"], horiz = TRUE, names.arg = rownames(cv_tab)[o], las = 1, cex.names = 0.75,
              col = "steelblue", border = NA, xlab = "RMSE of held-out provinces (percentage points)", xlim = c(0, max(cv_tab[, "RMSE"]) * 1.15))
text(cv_tab[o, "RMSE"] + 0.25, bp, sprintf("%.2f", cv_tab[o, "RMSE"]), cex = 0.75)
Figure 13. RMSE in leave-one-province-out cross-validation

Figure 13. RMSE in leave-one-province-out cross-validation

sessionInfo()
R version 4.5.1 (2025-06-13)
Platform: aarch64-apple-darwin20
Running under: macOS Sequoia 15.5

Matrix products: default
BLAS:   /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib 
LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1

locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8

time zone: Europe/Warsaw
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] rpart_4.1.24    cluster_2.1.8.1 ggrepel_0.9.6   ggplot2_4.0.3  
[5] dplyr_1.2.1     readxl_1.5.0   

loaded via a namespace (and not attached):
 [1] gtable_0.3.6       jsonlite_2.0.0     compiler_4.5.1     Rcpp_1.1.0        
 [5] tidyselect_1.2.1   jquerylib_0.1.4    scales_1.4.0       yaml_2.3.10       
 [9] fastmap_1.2.0      R6_2.6.1           labeling_0.4.3     generics_0.1.4    
[13] knitr_1.51         tibble_3.3.0       bslib_0.9.0        pillar_1.11.1     
[17] RColorBrewer_1.1-3 rlang_1.2.0        cachem_1.1.0       xfun_0.55         
[21] sass_0.4.10        S7_0.2.0           otel_0.2.0         cli_3.6.5         
[25] withr_3.0.2        magrittr_2.0.4     digest_0.6.37      grid_4.5.1        
[29] rstudioapi_0.18.0  lifecycle_1.0.5    vctrs_0.7.3        evaluate_1.0.5    
[33] glue_1.8.0         farver_2.1.2       cellranger_1.1.0   rmarkdown_2.30    
[37] tools_4.5.1        pkgconfig_2.0.3    htmltools_0.5.8.1