The chunk below auto-installs any missing required packages, so this
file should run on a fresh machine without manual
install.packages() calls. naniar is optional
(used only for the missing-data plot); if it fails to install, the
report falls back to a ggplot2-only alternative.
required_packages <- c("tidyverse", "readxl", "fixest", "scales", "gt", "plm")
optional_packages <- c("naniar")
missing_required <- setdiff(required_packages, rownames(installed.packages()))
if (length(missing_required) > 0) {
install.packages(missing_required, dependencies = TRUE)
}
invisible(lapply(required_packages, function(p)
suppressPackageStartupMessages(library(p, character.only = TRUE))))
missing_optional <- setdiff(optional_packages, rownames(installed.packages()))
if (length(missing_optional) > 0) {
tryCatch(install.packages(missing_optional, dependencies = TRUE),
error = function(e) message("Optional package install failed; using fallback."))
}
naniar_available <- requireNamespace("naniar", quietly = TRUE)
if (naniar_available) library(naniar)
theme_set(theme_minimal(base_size = 11))
options(scipen = 999)
raw <- read_excel("/Users/jixiangmama/Desktop/工作/2026年9月/rawdata.xlsx")
raw <- raw %>% arrange(country, year)
Addresses feedback: explain why observation counts (N) vary across variables.
miss_summary <- raw %>%
summarise(across(everything(), ~ sum(is.na(.)))) %>%
pivot_longer(everything(), names_to = "variable", values_to = "n_missing") %>%
mutate(pct_missing = round(100 * n_missing / nrow(raw), 2)) %>%
arrange(desc(n_missing))
miss_by_country <- raw %>%
group_by(country) %>%
summarise(across(where(is.numeric), ~ sum(is.na(.)))) %>%
mutate(total_missing = rowSums(across(where(is.numeric)))) %>%
arrange(desc(total_missing)) %>%
select(country, total_missing) %>%
head(15)
miss_by_year <- raw %>%
group_by(year) %>%
summarise(across(where(is.numeric), ~ sum(is.na(.)))) %>%
mutate(total_missing = rowSums(across(where(is.numeric), .fns = ~.x, .names = NULL))) %>%
select(year, total_missing) %>%
arrange(desc(total_missing)) %>%
head(15)
knitr::kable(miss_by_country, caption = "Countries with the most missing values")
| country | total_missing |
|---|---|
| Afghanistan | 0 |
| Albania | 0 |
| Algeria | 0 |
| Angola | 0 |
| Argentina | 0 |
| Armenia | 0 |
| Australia | 0 |
| Austria | 0 |
| Azerbaijan | 0 |
| Bahrain | 0 |
| Bangladesh | 0 |
| Belarus | 0 |
| Belgium | 0 |
| Benin | 0 |
| Bolivia | 0 |
knitr::kable(miss_by_year, caption = "Years with the most missing values")
| year | total_missing |
|---|---|
| 2023 | 2023 |
| 2022 | 2022 |
| 2021 | 2021 |
| 2020 | 2020 |
| 2019 | 2019 |
| 2018 | 2018 |
| 2017 | 2017 |
| 2016 | 2016 |
| 2015 | 2015 |
| 2014 | 2014 |
| 2013 | 2013 |
| 2012 | 2012 |
| 2011 | 2011 |
| 2010 | 2010 |
| 2009 | 2009 |
if (naniar_available) {
vis_miss(raw, warn_large_data = FALSE) +
labs(title = "Figure A1. Missing Data Overview",
subtitle = "Shows missingness by variable and observation")
} else {
miss_summary %>%
filter(pct_missing > 0) %>%
ggplot(aes(x = reorder(variable, pct_missing), y = pct_missing)) +
geom_col(fill = "#C44E52") +
coord_flip() +
labs(title = "Figure A1. Missing Data by Variable (fallback)",
x = NULL, y = "Missing (%)")
}
Addresses feedback: reader-friendly labels; explain why N varies.
desc_vars <- c("gini_di","gini_mk","financial_openness","population","GDP","inflation","FD",
"trade","urban","tertiary","foreign_asset","age_dependence","unemployment",
"government_expenditure","saving",
"p0p20_inc","p20p40_inc","p40p60_inc","p60p80_inc","p80p100_inc",
"p90p100_inc","p95p100_inc","p99p100_inc","p999p100_inc")
desc_vars <- desc_vars[desc_vars %in% names(raw)]
table1_desc <- raw %>%
select(all_of(desc_vars)) %>%
summarise(across(everything(),
list(N = ~sum(!is.na(.)),
Mean = ~mean(., na.rm = TRUE),
SD = ~sd(., na.rm = TRUE),
Min = ~min(., na.rm = TRUE),
Max = ~max(., na.rm = TRUE)))) %>%
pivot_longer(everything(), names_to = c("Variable", ".value"),
names_pattern = "(.*)_(N|Mean|SD|Min|Max)")
knitr::kable(table1_desc, digits = 4,
caption = "Table 1. Descriptive Statistics of Main Variables (balanced panel, no missing observations)")
| Variable | N | Mean | SD | Min | Max |
|---|---|---|---|---|---|
| gini_di | 7056 | 0.3846 | 0.0930 | 0.2030 | 0.6330 |
| gini_mk | 7056 | 0.4439 | 0.0725 | 0.2673 | 0.7200 |
| financial_openness | 7056 | 0.4668 | 0.2306 | 0.0000 | 1.0000 |
| population | 7056 | 0.5882 | 1.3030 | 0.0078 | 14.2474 |
| GDP | 7056 | 25.8339 | 2.1076 | 18.7601 | 30.7832 |
| inflation | 7056 | 0.1170 | 0.6046 | -0.0795 | 14.8623 |
| FD | 7056 | 0.5208 | 0.3927 | 0.0050 | 1.8524 |
| trade | 7056 | 0.9332 | 0.2825 | 0.1723 | 1.7044 |
| urban | 7056 | 0.4777 | 0.2076 | 0.0400 | 0.9656 |
| tertiary | 7056 | 0.3439 | 0.2296 | 0.0030 | 1.0671 |
| foreign_asset | 7056 | 0.4229 | 0.4466 | -0.8813 | 1.7000 |
| age_dependence | 7056 | 0.5618 | 0.1179 | 0.3571 | 0.8900 |
| unemployment | 7056 | 0.0723 | 0.0270 | 0.0020 | 0.1793 |
| government_expenditure | 7056 | 0.1577 | 0.0477 | 0.0100 | 0.3670 |
| saving | 7056 | 0.2186 | 0.0709 | -0.0608 | 0.4714 |
| p0p20_inc | 7056 | 0.0186 | 0.0078 | 0.0023 | 0.0404 |
| p20p40_inc | 7056 | 0.0750 | 0.0126 | 0.0214 | 0.1137 |
| p40p60_inc | 7056 | 0.1185 | 0.0174 | 0.0521 | 0.1719 |
| p60p80_inc | 7056 | 0.1850 | 0.0176 | 0.1184 | 0.2401 |
| p80p100_inc | 7056 | 0.6030 | 0.0477 | 0.4724 | 0.7868 |
| p90p100_inc | 7056 | 0.4363 | 0.0372 | 0.3218 | 0.5822 |
| p95p100_inc | 7056 | 0.3147 | 0.0275 | 0.2218 | 0.4079 |
| p99p100_inc | 7056 | 0.1523 | 0.0154 | 0.0855 | 0.2104 |
| p999p100_inc | 7056 | 0.0511 | 0.0076 | 0.0233 | 0.0839 |
Quintile shares should sum to ~1 (addresses: how the five groups relate to each other):
sum_check <- raw %>%
mutate(sum_share = p0p20_inc + p20p40_inc + p40p60_inc + p60p80_inc + p80p100_inc) %>%
summarise(mean_sum = mean(sum_share, na.rm = TRUE))
knitr::kable(sum_check, digits = 4,
caption = "Mean of the five quintile shares summed together (should be ~1.000)")
| mean_sum |
|---|
| 1 |
Figure 4.1 — Addresses feedback: figure numbering, caption must state what error bars represent.
quintile_long <- raw %>%
select(p0p20_inc, p20p40_inc, p40p60_inc, p60p80_inc, p80p100_inc) %>%
pivot_longer(everything(), names_to = "group", values_to = "share") %>%
mutate(group = factor(group,
levels = c("p0p20_inc","p20p40_inc","p40p60_inc","p60p80_inc","p80p100_inc"),
labels = c("Lowest 20%","2nd 20%","Middle 20%","4th 20%","Top 20%")))
quintile_long %>%
group_by(group) %>%
summarise(mean_share = mean(share, na.rm = TRUE), sd_share = sd(share, na.rm = TRUE)) %>%
ggplot(aes(group, mean_share)) +
geom_col(fill = "#4C72B0") +
geom_errorbar(aes(ymin = mean_share - sd_share, ymax = mean_share + sd_share), width = 0.2) +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
labs(title = "Figure 4.1. Average Income Share by Population Quintile",
subtitle = "Error bars = standard deviation (SD), not SE or a confidence interval",
x = NULL, y = "Average income share")
Figure 4.2 — Addresses feedback: clarify that these groups are nested/overlapping, not mutually exclusive.
top_long <- raw %>%
select(p999p100_inc, p99p100_inc, p95p100_inc, p90p100_inc, p80p100_inc) %>%
pivot_longer(everything(), names_to = "group", values_to = "share") %>%
mutate(group = factor(group,
levels = c("p999p100_inc","p99p100_inc","p95p100_inc","p90p100_inc","p80p100_inc"),
labels = c("Top 0.1%","Top 1%","Top 5%","Top 10%","Top 20%")))
top_long %>%
group_by(group) %>%
summarise(mean_share = mean(share, na.rm = TRUE), sd_share = sd(share, na.rm = TRUE)) %>%
ggplot(aes(group, mean_share)) +
geom_col(fill = "#C44E52") +
geom_errorbar(aes(ymin = mean_share - sd_share, ymax = mean_share + sd_share), width = 0.2) +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
labs(title = "Figure 4.2. Average Income Share of Top Income Groups",
subtitle = "Note: these 5 groups are nested, not mutually exclusive; error bars = SD",
x = NULL, y = "Average income share")
Figure 4.3 — income-share composition by financial-openness tercile (addresses: how the five shares relate to one another).
raw %>%
mutate(fo_tercile = ntile(financial_openness, 3),
fo_tercile = factor(fo_tercile, labels = c("Low FO","Medium FO","High FO"))) %>%
select(fo_tercile, p0p20_inc, p20p40_inc, p40p60_inc, p60p80_inc, p80p100_inc) %>%
pivot_longer(-fo_tercile, names_to = "group", values_to = "share") %>%
mutate(group = factor(group,
levels = c("p0p20_inc","p20p40_inc","p40p60_inc","p60p80_inc","p80p100_inc"),
labels = c("Lowest 20%","2nd 20%","Middle 20%","4th 20%","Top 20%"))) %>%
group_by(fo_tercile, group) %>%
summarise(mean_share = mean(share, na.rm = TRUE), .groups = "drop") %>%
ggplot(aes(fo_tercile, mean_share, fill = group)) +
geom_col(position = "stack") +
scale_y_continuous(labels = percent_format()) +
labs(title = "Figure 4.3. Income Share Composition by Financial Openness Tercile",
x = NULL, y = "Average income share", fill = "Income group")
Addresses feedback: clarify why the 2022 classification is used for a multi-year sample.
raw %>%
distinct(country, income_group_2022) %>%
count(income_group_2022) %>%
mutate(income_group_2022 = factor(income_group_2022,
levels = c("High income","Upper-middle income","Lower-middle income","Low income"))) %>%
ggplot(aes(x = "", y = n, fill = income_group_2022)) +
geom_col(width = 1, color = "white") +
coord_polar("y") +
labs(title = "Figure 3.1. Sample Countries by World Bank Income Group (2022)",
subtitle = "Single 2022 cross-sectional classification, not time-varying over 1975-2023",
fill = "Income group") +
theme_void()
Core fix: relies only on fixest’s own
coef/se/pvalue/nobs/r2
accessors, so tables render reliably regardless of other packages
installed.
control_labels <- c(
financial_openness = "Financial openness", L_financial_openness = "Financial openness (t-1)",
population = "Population", FD = "Financial development", GDP = "GDP (log)",
inflation = "Inflation", age_dependence = "Age dependence", trade = "Trade openness",
urban = "Urbanisation", tertiary = "Tertiary enrolment", foreign_asset = "Foreign net assets",
unemployment = "Unemployment", government_expenditure = "Government expenditure",
saving = "National saving"
)
stars_fun <- function(p) {
if (is.na(p)) return("")
if (p < 0.01) return("***"); if (p < 0.05) return("**"); if (p < 0.10) return("*")
return("")
}
reg_table_html <- function(models, var_order, col_names, note, extra_meta = NULL) {
header <- paste0("<tr><th>Variables</th>", paste0("<th>", col_names, "</th>", collapse = ""), "</tr>")
body <- ""
for (v in var_order) {
lbl <- if (v %in% names(control_labels)) control_labels[[v]] else v
coef_cells <- character(0); se_cells <- character(0)
for (m in models) {
cf <- tryCatch(coef(m), error = function(e) NULL)
if (!is.null(cf) && v %in% names(cf)) {
b <- cf[[v]]; s <- fixest::se(m)[[v]]; p <- fixest::pvalue(m)[[v]]
coef_cells <- c(coef_cells, sprintf("%.4f%s", b, stars_fun(p)))
se_cells <- c(se_cells, sprintf("(%.4f)", s))
} else { coef_cells <- c(coef_cells, ""); se_cells <- c(se_cells, "") }
}
body <- paste0(body, "<tr><td style='text-align:left'>", lbl, "</td>",
paste0("<td>", coef_cells, "</td>", collapse = ""), "</tr>")
body <- paste0(body, "<tr><td></td>",
paste0("<td style='color:#888;font-size:0.85em'>", se_cells, "</td>", collapse = ""), "</tr>")
}
meta <- ""
if (!is.null(extra_meta)) {
for (nm in names(extra_meta)) {
meta <- paste0(meta, "<tr><td>", nm, "</td>",
paste0("<td>", extra_meta[[nm]], "</td>", collapse = ""), "</tr>")
}
}
nobs_vals <- sapply(models, function(m) format(nobs(m), big.mark = ","))
r2_vals <- sapply(models, function(m) tryCatch(sprintf("%.4f", fixest::r2(m, "r2")), error = function(e) "NA"))
nobs_row <- paste0("<tr><td>Observations</td>", paste0("<td>", nobs_vals, "</td>", collapse = ""), "</tr>")
r2_row <- paste0("<tr><td>R-squared</td>", paste0("<td>", r2_vals, "</td>", collapse = ""), "</tr>")
paste0("<table class='table table-striped' style='font-size:0.85em'><thead>", header,
"</thead><tbody>", body, meta, nobs_row, r2_row, "</tbody></table>",
"<p style='font-size:0.8em;color:#666'>", note, "</p>")
}
REG_NOTE <- "Standard errors clustered at the country level in parentheses. ***, **, * denote significance at the 1%, 5%, 10% levels."
Addresses feedback: justify clustering; compare alternative SE choices; explain the practical size of the coefficient.
baseline_controls <- c("population","FD","GDP","inflation","age_dependence","trade","urban","tertiary","foreign_asset")
fml_ctrl <- paste(baseline_controls, collapse = " + ")
m1 <- feols(as.formula(paste0("gini_mk ~ financial_openness")), data = raw)
m2 <- feols(as.formula(paste0("gini_mk ~ financial_openness + ", fml_ctrl)), data = raw)
m3 <- feols(as.formula(paste0("gini_mk ~ financial_openness + ", fml_ctrl, " | country")), data = raw)
m4 <- feols(as.formula(paste0("gini_mk ~ financial_openness + ", fml_ctrl, " | country + year")),
data = raw, cluster = ~country)
m4_hc1 <- feols(as.formula(paste0("gini_mk ~ financial_openness + ", fml_ctrl, " | country + year")),
data = raw, vcov = "hetero")
m4_twway <- feols(as.formula(paste0("gini_mk ~ financial_openness + ", fml_ctrl, " | country + year")),
data = raw, cluster = ~country + year)
cat(reg_table_html(
models = list(m1, m2, m3, m4),
var_order = c("financial_openness", baseline_controls),
col_names = c("(1)","(2)","(3)","(4)"),
note = paste0("Table 2. Effect of financial openness on the pre-tax Gini coefficient (gini_mk). ", REG_NOTE),
extra_meta = list("Country fixed effects" = c("No","No","Yes","Yes"),
"Year fixed effects" = c("No","No","No","Yes"))
))
| Variables | (1) | (2) | (3) | (4) |
|---|---|---|---|---|
| Financial openness | -0.1107*** | 0.0148** | 0.0911*** | 0.0821*** |
| (0.0035) | (0.0065) | (0.0029) | (0.0037) | |
| Population | 0.0115*** | 0.0003 | -0.0000 | |
| (0.0007) | (0.0007) | (0.0009) | ||
| Financial development | 0.0033 | 0.0210*** | 0.0254*** | |
| (0.0033) | (0.0025) | (0.0031) | ||
| GDP (log) | -0.0017** | -0.0057*** | -0.0050*** | |
| (0.0007) | (0.0009) | (0.0015) | ||
| Inflation | 0.0216*** | 0.0213*** | 0.0213*** | |
| (0.0012) | (0.0003) | (0.0012) | ||
| Age dependence | -0.0775*** | 0.0640*** | 0.0429*** | |
| (0.0149) | (0.0054) | (0.0063) | ||
| Trade openness | -0.0309*** | -0.0011 | -0.0004 | |
| (0.0059) | (0.0018) | (0.0016) | ||
| Urbanisation | -0.0551*** | -0.0087 | -0.0024 | |
| (0.0081) | (0.0077) | (0.0099) | ||
| Tertiary enrolment | -0.0593*** | -0.0058* | 0.0008 | |
| (0.0059) | (0.0034) | (0.0050) | ||
| Foreign net assets | -0.0248*** | -0.0008 | -0.0009 | |
| (0.0025) | (0.0006) | (0.0006) | ||
| Country fixed effects | No | No | Yes | Yes |
| Year fixed effects | No | No | No | Yes |
| Observations | 7,056 | 7,056 | 7,056 | 7,056 |
| R-squared | 0.1239 | 0.2645 | 0.9680 | 0.9691 |
Table 2. Effect of financial openness on the pre-tax Gini coefficient (gini_mk). Standard errors clustered at the country level in parentheses. , , denote significance at the 1%, 5%, 10% levels.
gini_range <- diff(range(raw$gini_mk, na.rm = TRUE))
coef_fo <- coef(m4)["financial_openness"]
cat(sprintf(
"A 0.1-unit increase in financial openness is associated with an average increase of about %.4f in gini_mk, equivalent to %.2f%% of the observed range of gini_mk (%.3f).",
coef_fo * 0.1, 100 * (coef_fo * 0.1) / gini_range, gini_range))
## A 0.1-unit increase in financial openness is associated with an average increase of about 0.0082 in gini_mk, equivalent to 1.81% of the observed range of gini_mk (0.453).
cat(reg_table_html(
models = list(m4, m4_hc1, m4_twway),
var_order = "financial_openness",
col_names = c("Clustered (country)","Robust (HC1)","Two-way clustered"),
note = "Compares whether significance holds under three SE assumptions."
))
| Variables | Clustered (country) | Robust (HC1) | Two-way clustered |
|---|---|---|---|
| Financial openness | 0.0821*** | 0.0821*** | 0.0821*** |
| (0.0037) | (0.0031) | (0.0046) | |
| Observations | 7,056 | 7,056 | 7,056 |
| R-squared | 0.9691 | 0.9691 | 0.9691 |
Compares whether significance holds under three SE assumptions.
Addresses feedback: a one-period lag does not fully rule out reverse causality; a dynamic-panel GMM check is added as a further robustness test.
raw <- raw %>% group_by(country) %>%
mutate(L_financial_openness = lag(financial_openness, 1)) %>% ungroup()
m5_1 <- feols(gini_mk ~ L_financial_openness | country + year, data = raw, cluster = ~country)
m5_2 <- feols(as.formula(paste0("gini_mk ~ L_financial_openness + ", fml_ctrl, " | country + year")),
data = raw, cluster = ~country)
extra_controls <- c("unemployment","government_expenditure","saving")
m5_3 <- feols(as.formula(paste0("gini_mk ~ financial_openness + ", fml_ctrl, " + ",
paste(extra_controls, collapse = " + "), " | country + year")),
data = raw, cluster = ~country)
cat(reg_table_html(
models = list(m5_1, m5_2, m5_3),
var_order = c("L_financial_openness","financial_openness", baseline_controls, extra_controls),
col_names = c("(1) Lagged FO","(2) Lagged FO + controls","(3) Additional controls"),
note = paste0("Table 5. Robustness checks. ", REG_NOTE),
extra_meta = list("Country & year FE" = c("Yes","Yes","Yes"))
))
| Variables |
|
|
|
|---|---|---|---|
| Financial openness (t-1) | 0.0899*** | 0.0821*** | |
| (0.0051) | (0.0037) | ||
| Financial openness | 0.0815*** | ||
| (0.0037) | |||
| Population | -0.0000 | 0.0000 | |
| (0.0009) | (0.0009) | ||
| Financial development | 0.0254*** | 0.0251*** | |
| (0.0031) | (0.0031) | ||
| GDP (log) | -0.0050*** | -0.0051*** | |
| (0.0015) | (0.0015) | ||
| Inflation | 0.0213*** | 0.0214*** | |
| (0.0012) | (0.0012) | ||
| Age dependence | 0.0429*** | 0.0430*** | |
| (0.0063) | (0.0063) | ||
| Trade openness | -0.0004 | -0.0003 | |
| (0.0016) | (0.0016) | ||
| Urbanisation | -0.0024 | -0.0022 | |
| (0.0099) | (0.0098) | ||
| Tertiary enrolment | 0.0008 | 0.0012 | |
| (0.0050) | (0.0050) | ||
| Foreign net assets | -0.0009 | -0.0008 | |
| (0.0006) | (0.0007) | ||
| Unemployment | 0.0365*** | ||
| (0.0069) | |||
| Government expenditure | 0.0087** | ||
| (0.0041) | |||
| National saving | -0.0001 | ||
| (0.0023) | |||
| Country & year FE | Yes | Yes | Yes |
| Observations | 7,056 | 7,056 | 7,056 |
| R-squared | 0.9369 | 0.9691 | 0.9693 |
Table 5. Robustness checks. Standard errors clustered at the country level in parentheses. , , denote significance at the 1%, 5%, 10% levels.
pdata <- pdata.frame(raw, index = c("country","year"))
m5_gmm <- tryCatch(
pgmm(gini_mk ~ lag(gini_mk, 1) + financial_openness + lag(financial_openness,1) |
lag(gini_mk, 2:99),
data = pdata, effect = "twoways", model = "twosteps", transformation = "d"),
error = function(e) NULL
)
if (!is.null(m5_gmm)) {
cat(sprintf("Arellano-Bond GMM: financial_openness coefficient ≈ %.4f (see summary(m5_gmm) for full output).",
coef(m5_gmm)["financial_openness"]))
} else {
cat("GMM did not converge; lag order/instruments may need adjusting.")
}
## Arellano-Bond GMM: financial_openness coefficient ≈ 0.0556 (see summary(m5_gmm) for full output).
Addresses feedback: avoid causal language.
raw %>%
ggplot(aes(financial_openness, gini_mk, color = income_group_2022)) +
geom_point(alpha = 0.25, size = 0.8) +
geom_smooth(aes(group = 1), method = "loess", color = "black", se = TRUE) +
labs(title = "Figure 4.5. Financial Openness vs. Pre-tax Gini Coefficient",
subtitle = "Shows an association, not a causal relationship",
x = "Financial openness", y = "Pre-tax Gini coefficient (gini_mk)", color = "Income group (2022)")