Specific questions: Do E-V bilingual children show a noun/verb bias? If so, are they consistent across languages? - Does noun/verb dominance also change with age / other demographic characteristics? Possible big question: How should we characterize noun-verb dominance? Is noun-verb dominance driven by conceptual underpinnings (e.g., Mcnamara 1972) vs. language input features (Tardif 1996; also Gentner 1982)?
H1: Cross-linguistic consistency. Noun/verb bias is consistent across the two languages of E–V bilingual children. This pattern would be consistent with noun/verb dominance being underpinned primarily by language-independent conceptual differences.
H2: Cross-linguistic inconsistency. Noun/verb bias differs across the two languages of E–V bilingual children. This pattern would be consistent with noun/verb dominance being shaped primarily by language-specific input features. Importantly, cross-linguistic inconsistency could take at least two forms:
H2.1: Monolingual-like biases: Bilingual children show a noun/verb bias in each language that is comparable to the bias observed in monolingual children acquiring that language. This would suggest that each language’s bias is largely shaped by its own input.
H2.2: Language-specific biases with a lexical “accent”: Bilingual children show different noun/verb biases across their two languages, but each language’s bias is shifted relative to the corresponding monolingual pattern. This would suggest that language-specific input interacts with the bilingual child’s cross-linguistic lexical experience.
Note: We would need additional data from monolingual speakers to differentiate between H2.1 and H2.2.
The existing 2PL models (02_IRT_analysis_en.Rmd,
02_IRT_analysis_vi_0716.Rmd) estimate a single,
unidimensional production-vocabulary ability per child. These were the
best fitting models for English and Vietnamese vocabulary respectively
(compared with unidimensional Rasch/3PL/4PL). Here we additionally
estimate noun-specific and verb-specific ability using a confirmatory
bifactor model: every item loads on a general factor (G), and items
belonging to noun or verb categories additionally load on a
noun-specific (S1) or verb-specific (S2) factor. Each child therefore
gets three ability estimates per language:
ability_general (G): overall production vocabulary, net
of noun/verb group membershipability_noun (S1): noun ability beyond what is
explained by general vocabulary abilityability_verb (S2): verb ability beyond what is
explained by general vocabulary abilityItem-to-domain mapping is based on item_kind (the CDI
category embedded in each column name, e.g. action_words,
animals, …):
action_wordsanimals,
body_parts, clothing, food_drink,
furniture_rooms, household,
outdoor, people, places,
toys, vehiclesitem_kinds (descriptive_words,
pronouns, quantifiers,
helping_verbs, question_words,
connecting_words, locations,
games_routines, sounds,
time_words) load on the general factor only.mirt() fit using rectangular
quadrature does not scale to ~680 items and is extremely slow to
converge. method = "QMCEM" (quasi-Monte Carlo integration)
is mirt’s documented fix for 3+ dimensional models.|a| > 50,
NaN log-likelihood) given the current sample size (~100
usable children per language). Log-normal priors on
a1/a2/a3 keep discriminations
positive and bounded, which resolves this.mirt() directly (not the bfactor()
convenience wrapper) because bfactor()’s fast
dimension-reduction EM algorithm does not respect the PRIOR
statements needed to stabilize the fit at this sample size.noun_kinds <- c("animals", "body_parts", "clothing", "food_drink", "furniture_rooms",
"household", "outdoor", "people", "places", "toys", "vehicles")
verb_kinds <- c("action_words")
lang_config <- list(
en = list(
data_file = "data/EnglishAmericanWS_Bui_data_redact.csv",
fields_file = "data/EnglishAmericanWS_Bui_fields.csv",
prefix = "cat_eng_",
out_dir = "models/en"
),
vi = list(
data_file = "data/VietnameseWS_Bui_data.csv",
fields_file = "data/VietnameseWS_Bui_fields.csv",
prefix = "cat_vie_",
out_dir = "models/vi"
)
)
# For a demo data frame with language_1..4 and lang_1_pct_active..lang_4_pct_active
# (lang_i_pct_active is the % of the child's *active* language use taken up by
# whichever language sits in slot i -- these sum to ~100 across the 4 slots),
# returns each child's % active use of `lang_name`, or 0 if `lang_name` is not
# among their reported languages.
get_pct_active_lang <- function(d_demo, lang_name) {
lang_mat <- as.matrix(d_demo[, c("language_1", "language_2", "language_3", "language_4")])
pct_mat <- as.matrix(d_demo[, c("lang_1_pct_active", "lang_2_pct_active", "lang_3_pct_active", "lang_4_pct_active")])
is_match <- lang_mat == lang_name
is_match[is.na(is_match)] <- FALSE
pct_mat[!is_match] <- 0
rowSums(pct_mat, na.rm = TRUE)
}
load_lang_data <- function(lang) {
cfg <- lang_config[[lang]]
d_wide <- read_csv(cfg$data_file, show_col_types = FALSE) %>%
select(response_id, starts_with(cfg$prefix)) %>%
mutate(across(everything(), ~replace_na(.x, 0)))
d_demo <- read_csv(cfg$data_file, show_col_types = FALSE) %>%
select(row_id, response_id, age, sex, language_1, ppl_1_edu,
language_1, language_2, language_3, language_4,
lang_1_pct_active, lang_2_pct_active, lang_3_pct_active, lang_4_pct_active) %>%
mutate(
pct_active_english = get_pct_active_lang(., "English"),
pct_active_vietnamese = get_pct_active_lang(., "Vietnamese")
)
d_items <- read_csv(cfg$fields_file, show_col_types = FALSE) |>
filter(group == "item", type == "word") |>
mutate(
definition = make.names(column),
item_kind = str_remove(column, paste0("^", cfg$prefix)) %>% str_remove("___.*$"),
item_definition = str_remove(column, "^.*?___"),
) |>
rename(item_id = field) |>
select(-c(group, type))
list(d_wide = d_wide, d_demo = d_demo, d_items = d_items)
}
en_data <- load_lang_data("en")
d_wide_en <- en_data$d_wide
d_demo_en <- en_data$d_demo
d_items_en <- en_data$d_items
vi_data <- load_lang_data("vi")
d_wide_vi <- vi_data$d_wide
d_demo_vi <- vi_data$d_demo
d_items_vi <- vi_data$d_items
We are currently not filtering any data, but this function is written here in case we want to filter kids who do not produce any English words etc.
# to_remove would drop children too young to be producing words (<12mo),
# who produce zero words at >=12mo, or who are at ceiling (produce every
# item) -- but that exclusion is currently disabled below. In particular,
# the 42 English zero-producers still produce substantial Vietnamese
# vocabulary (median ~244 words), so they're kept rather than treated as
# non-responders. to_remove is still computed for reference/diagnostics.
filter_d_mat <- function(d_wide, d_demo, d_items) {
d_mat <- d_wide %>% data.frame %>% select(-response_id) %>% data.matrix
stopifnot(all(colnames(d_mat) == d_items$definition))
d_demo$production <- rowSums(d_mat)
too_young <- which(d_demo$age < 12)
no_words <- which(d_demo$production == 0 & d_demo$age >= 12)
ceiling <- which(d_demo$production == ncol(d_mat))
to_remove <- unique(c(too_young, no_words, ceiling))
# if (length(to_remove) > 0) {
# d_mat <- d_mat[-to_remove, ]
# d_demo <- d_demo[-to_remove, ]
# }
list(d_mat = d_mat, d_demo = d_demo)
}
en_filtered <- filter_d_mat(d_wide_en, d_demo_en, d_items_en)
d_mat_en <- en_filtered$d_mat
d_demo_en <- en_filtered$d_demo
vi_filtered <- filter_d_mat(d_wide_vi, d_demo_vi, d_items_vi)
d_mat_vi <- vi_filtered$d_mat
d_demo_vi <- vi_filtered$d_demo
# Fits the same G/S1(noun)/S2(verb) bifactor structure at a given itemtype
# (1PL/2PL/3PL/4PL), all treated the same way, so the four variants can be
# compared the way the original 02_IRT_analysis Rmds compare unidimensional
# 1PL/2PL/3PL/4PL.
#
# itemtype = "Rasch" (1PL) fixes all discriminations to 1, so there is
# nothing to put a prior on for a1/a2/a3 (only "2PL"/"3PL"/"4PL" freely
# estimate discriminations, and need lnorm priors to avoid Heywood cases --
# see the modeling notes above).
fit_bifactor_variant <- function(d_mat, d_items, out_dir, itemtype, ncycles = 4000, seed = 1234) {
noun_idx <- which(d_items$item_kind %in% noun_kinds)
verb_idx <- which(d_items$item_kind %in% verb_kinds)
n_items <- ncol(d_mat)
model_lines <- c(
paste0("G = 1-", n_items),
paste0("S1 = ", paste(noun_idx, collapse = ",")),
paste0("S2 = ", paste(verb_idx, collapse = ","))
)
if (itemtype != "Rasch") {
model_lines <- c(model_lines, paste0(
"PRIOR = (1-", n_items, ", a1, lnorm, 0, 0.5), (",
paste(noun_idx, collapse = ","), ", a2, lnorm, 0, 0.5), (",
paste(verb_idx, collapse = ","), ", a3, lnorm, 0, 0.5)"
))
}
mod_def <- mirt.model(paste(model_lines, collapse = "\n"))
set.seed(seed)
mod <- mirt(d_mat, mod_def, itemtype = itemtype, method = "QMCEM",
verbose = TRUE, technical = list(NCYCLES = ncycles))
dir.create(out_dir, showWarnings = FALSE, recursive = TRUE)
label <- c(Rasch = "1pl", `2PL` = "2pl", `3PL` = "3pl", `4PL` = "4pl")[[itemtype]]
saveRDS(mod, file = file.path(out_dir, paste0("mod_bifactor_", label, ".Rds")))
mod
}
# builds one fit-statistics row per model (AIC/SABIC/HQ/BIC/logLik) so the
# four bifactor variants can be compared side by side, in addition to the
# pairwise nested LRTs from get_anova_table() in IRT_helpers.R
get_fit_table <- function(models, model_names) {
tab <- dplyr::bind_rows(lapply(models, function(m) as.data.frame(anova(m))))
tab <- data.frame(Model = model_names, tab, row.names = NULL)
tab
}
# item coefficients (with domain labels) and per-child G/S1(noun)/S2(verb)
# ability estimates (+ SE) for a fitted bifactor model -- used on whichever
# variant (typically 2PL) is chosen as the primary model for the rest of
# this report.
extract_bifactor_summary <- function(mod, d_demo, d_items) {
coefs <- as_tibble(coef(mod, simplify = TRUE)$items) %>%
mutate(definition = rownames(coef(mod, simplify = TRUE)$items)) %>%
left_join(d_items, by = "definition") %>%
mutate(domain = case_when(
item_kind %in% verb_kinds ~ "verb",
item_kind %in% noun_kinds ~ "noun",
TRUE ~ "other"
))
fs <- fscores(mod, method = "MAP", full.scores.SE = TRUE)
fscores_tab <- tibble(
row_id = d_demo$row_id,
response_id = d_demo$response_id,
ability_general = fs[, "G"],
ability_noun = fs[, "S1"],
ability_verb = fs[, "S2"],
ability_general_SE = fs[, "SE_G"],
ability_noun_SE = fs[, "SE_S1"],
ability_verb_SE = fs[, "SE_S2"]
)
list(coefs = coefs, fscores = fscores_tab)
}
# Nonparametric item-resampling bootstrap CI for ability_noun (S1), holding the
# already-fitted item parameters fixed. Refitting the full 3-dimensional
# bifactor model per replicate isn't feasible -- a single fit already takes
# 40-90+ minutes -- so instead, for each child and each of B replicates, items
# are resampled with replacement (equivalently: re-weighted by how many times
# each item is drawn) and that child's (G, S1, S2) MAP is re-estimated under
# the model's fixed, already-estimated item parameters via a small weighted
# penalized-logistic optimization; the 2.5th/97.5th percentiles of the
# resulting S1 draws become the per-child CI.
# cache_file, if given, is read (skipping the bootstrap entirely) when it
# already exists, and written after computing otherwise -- same on-disk
# caching convention as fit_bifactor_variant()'s saveRDS().
#
# IMPORTANT: the prior variances on (G, S1, S2) are NOT fixed at 1 in general
# -- e.g. the Rasch/1PL bifactor variant fixes all item discriminations to 1
# for identification, which forces the *latent variance* to freely absorb the
# item-difficulty spread instead (checked: English's fitted var(G) = 24.55,
# var(S1) = 0.87, var(S2) = 0.58; Vietnamese's differ again). An earlier
# version of this function assumed independent N(0,1) priors, which silently
# used the wrong (much tighter) prior on G in particular, over-shrinking G
# and biasing S1 to compensate. Pulling the actual fitted variances from
# coef(mod, simplify=TRUE)$cov (confirmed diagonal / orthogonal for these
# models) fixes this.
bootstrap_noun_ci <- function(mod, d_mat, d_demo, B = 1000, seed = 1234, cache_file = NULL) {
if (!is.null(cache_file) && file.exists(cache_file)) {
return(readRDS(cache_file))
}
cf <- coef(mod, simplify = TRUE)$items
a1 <- cf[, "a1"]; a2 <- cf[, "a2"]; a3 <- cf[, "a3"]
d_par <- cf[, "d"]; g <- cf[, "g"]; u <- cf[, "u"]
n_items <- nrow(cf)
prior_var <- diag(coef(mod, simplify = TRUE)$cov)[c("G", "S1", "S2")]
neg_log_post <- function(theta, y, w) {
z <- a1 * theta[1] + a2 * theta[2] + a3 * theta[3] + d_par
p <- g + (u - g) / (1 + exp(-z))
p <- pmin(pmax(p, 1e-10), 1 - 1e-10)
-(sum(w * (y * log(p) + (1 - y) * log(1 - p))) +
sum(dnorm(theta, mean = 0, sd = sqrt(prior_var), log = TRUE)))
}
gr_neg_log_post <- function(theta, y, w) {
z <- a1 * theta[1] + a2 * theta[2] + a3 * theta[3] + d_par
ez <- 1 / (1 + exp(-z))
p <- g + (u - g) * ez
p <- pmin(pmax(p, 1e-10), 1 - 1e-10)
dp_dz <- (u - g) * ez * (1 - ez)
dll_dz <- w * (y / p - (1 - y) / (1 - p)) * dp_dz
-c(sum(dll_dz * a1) - theta[1] / prior_var[1],
sum(dll_dz * a2) - theta[2] / prior_var[2],
sum(dll_dz * a3) - theta[3] / prior_var[3])
}
fs <- fscores(mod, method = "MAP")
n_people <- nrow(d_mat)
set.seed(seed)
boot_S1 <- matrix(NA_real_, n_people, B)
for (i in seq_len(n_people)) {
y <- d_mat[i, ]
start <- fs[i, ]
for (b in seq_len(B)) {
w <- tabulate(sample.int(n_items, n_items, replace = TRUE), nbins = n_items)
boot_S1[i, b] <- optim(start, neg_log_post, gr_neg_log_post, y = y, w = w, method = "BFGS")$par[2]
}
}
out <- tibble(
row_id = d_demo$row_id,
response_id = d_demo$response_id,
ability_noun_boot_mean = rowMeans(boot_S1),
ability_noun_boot_SD = apply(boot_S1, 1, sd),
ability_noun_CI_lower = apply(boot_S1, 1, quantile, probs = 0.025),
ability_noun_CI_upper = apply(boot_S1, 1, quantile, probs = 0.975)
)
if (!is.null(cache_file)) saveRDS(out, cache_file)
out
}
en_bifactor_1pl <- fit_bifactor_variant(d_mat_en, d_items_en, out_dir = "models/en", itemtype = "Rasch")
en_bifactor_2pl <- fit_bifactor_variant(d_mat_en, d_items_en, out_dir = "models/en", itemtype = "2PL")
en_bifactor_3pl <- fit_bifactor_variant(d_mat_en, d_items_en, out_dir = "models/en", itemtype = "3PL")
en_bifactor_4pl <- fit_bifactor_variant(d_mat_en, d_items_en, out_dir = "models/en", itemtype = "4PL")
load("models/en/mod_2pl.Rds") # unidimensional baseline
mod_bifactor_1pl <- readRDS("models/en/mod_bifactor_1pl.Rds")
mod_bifactor_2pl <- readRDS("models/en/mod_bifactor_2pl.Rds")
mod_bifactor_3pl <- readRDS("models/en/mod_bifactor_3pl.Rds")
mod_bifactor_4pl <- readRDS("models/en/mod_bifactor_4pl.Rds")
Compares the bifactor structure (G + noun + verb factors) fit at each
itemtype, plus the plain unidimensional 2PL (mod_2pl) as a
baseline.
mc1_en <- get_anova_table(mod_bifactor_1pl, mod_bifactor_2pl, c("Bifactor 1PL", "Bifactor 2PL"))
mc2_en <- get_anova_table(mod_bifactor_1pl, mod_bifactor_3pl, c("Bifactor 1PL", "Bifactor 3PL"))
mc3_en <- get_anova_table(mod_bifactor_1pl, mod_bifactor_4pl, c("Bifactor 1PL", "Bifactor 4PL"))
mc4_en <- get_anova_table(mod_2pl, mod_bifactor_1pl, c("Unidim 2PL", "Bifactor 1PL"))
kable(rbind(mc1_en, mc2_en, mc3_en, mc4_en), digits = 2,
caption = "English: pairwise nested-model comparisons.") %>%
html_table_width(c(90, 90, 90, 90, 50))
| Model | AIC | BIC | logLik | df |
|---|---|---|---|---|
| Bifactor 1PL | 39314.19 | 41335.98 | -18973.10 | NA |
| Bifactor 2PL | 40909.21 | 46315.41 | -18625.60 | 1145 |
| Bifactor 1PL | 39314.19 | 41335.98 | -18973.10 | NA |
| Bifactor 3PL | 42201.92 | 49621.05 | -18590.96 | 1826 |
| Bifactor 1PL | 39314.19 | 41335.98 | -18973.10 | NA |
| Bifactor 4PL | 43372.01 | 52804.06 | -18495.01 | 2507 |
| Unidim 2PL | 40365.38 | 44391.22 | -18820.69 | NA |
| Bifactor 1PL | 39314.19 | 41335.98 | -18973.10 | -678 |
bifactor_variants_en <- list(`1PL` = mod_bifactor_1pl, `2PL` = mod_bifactor_2pl, `3PL` = mod_bifactor_3pl, `4PL` = mod_bifactor_4pl, `Unidim2PL` = mod_2pl)
fit_table_en <- get_fit_table(bifactor_variants_en, names(bifactor_variants_en))
kable(fit_table_en, digits = 2,
caption = "English: fit statistics across the four bifactor itemtypes.") %>%
html_table_width(c(90, 90, 90, 90, 90))
| Model | AIC | SABIC | HQ | BIC | logLik | logPost |
|---|---|---|---|---|---|---|
| 1PL | 39314.19 | 39171.76 | 40135.76 | 41335.98 | -18973.10 | NA |
| 2PL | 40909.21 | 40528.34 | 43106.07 | 46315.41 | -18625.60 | -19303.36 |
| 3PL | 42201.92 | 41679.24 | 45216.75 | 49621.05 | -18590.96 | -19291.79 |
| 4PL | 43372.01 | 42707.52 | 47204.81 | 52804.06 | -18495.01 | -19183.33 |
| Unidim2PL | 40365.38 | 40081.76 | 42001.32 | 44391.22 | -18820.69 | NA |
Selects the best-fitting bifactor variant by BIC. All of the
inferential analysis below (noun/verb dominance, age effects, etc.) is
run on this selected model, referred to from here on as
mod_bifactor.
The best model is the Bifactor 1PL.
best_variant_en <- names(bifactor_variants_en)[which.min(fit_table_en$BIC)]
mod_bifactor <- bifactor_variants_en[[best_variant_en]]
cat("Best-fitting English bifactor variant (by BIC):", best_variant_en)
## Best-fitting English bifactor variant (by BIC): 1PL
en_bifactor_summary <- extract_bifactor_summary(mod_bifactor, d_demo_en, d_items_en)
en_coefs <- en_bifactor_summary$coefs
en_fscores <- en_bifactor_summary$fscores
cat("Converged:", extract.mirt(mod_bifactor, "converged"),
"| Iterations:", extract.mirt(mod_bifactor, "iterations"),
"| logLik:", round(logLik(mod_bifactor), 1))
## Converged: TRUE | Iterations: 115 | logLik: -18973.1
Investigate ability_noun and ability_verb
for spread. Note that these estimates are not on the same scale.
summary(en_fscores$ability_noun)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## -1.7579 -0.4934 -0.1396 -0.1017 0.1870 4.4349
summary(en_fscores$ability_verb)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## -1.573131 -0.279244 -0.007532 -0.018926 0.068660 2.350589
sd(en_fscores$ability_noun)
## [1] 0.7762871
sd(en_fscores$ability_verb)
## [1] 0.5171733
bootstrap_noun_ci() (defined above) gives each child a
nonparametric 95% bootstrap CI on ability_noun, built by
resampling items with replacement (B = 1000 replicates) and
re-estimating that child’s (ability-general,
ability-noun, ability-verb) MAP under the
fixed, already-fitted item parameters – see the function definition for
why a full model-refit bootstrap isn’t used. Results (the point
estimates and CI bounds, not the raw B draws) are cached to
models/en/noun_boot_ci_<variant>.Rds. We also attach
demographics (age, % active English/Vietnamese exposure) here for use in
the cross-language comparison below.
en_noun_boot <- bootstrap_noun_ci(mod_bifactor, d_mat_en, d_demo_en, B = 1000,
cache_file = file.path("models/en", paste0("noun_boot_ci_", best_variant_en, ".Rds")))
en_fscores <- en_fscores %>% left_join(en_noun_boot, by = c("row_id", "response_id"))
noun_idx_en <- which(d_items_en$item_kind %in% noun_kinds)
noun_raw_en <- rowSums(d_mat_en[, noun_idx_en])
en_fscores_demog <- en_fscores %>%
left_join(d_demo_en %>% select(row_id, response_id, age, pct_active_english, pct_active_vietnamese, production),
by = c("row_id", "response_id")) %>%
mutate(noun_raw = noun_raw_en, floor_noun = noun_raw == 0)
summary(en_fscores$ability_noun_boot_SD)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.01364 0.01463 0.22220 0.20442 0.30295 0.45878
summary(en_fscores$ability_noun_CI_upper - en_fscores$ability_noun_CI_lower)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.05252 0.05732 0.86037 0.79391 1.18746 1.85418
ability_noun and ability_verb share a
nominal prior scale (the bifactor model fixes all three factor variances
to 1), but their empirical spread differs. Each specific factor
is measured by different items, and there are more noun items than verb
items, so MAP shrinkage compresses them to different degrees
(e.g. sd(ability_verb) is meaningfully smaller than
sd(ability_noun) in this sample).
ggplot(aes(x = ability_noun, y = ability_verb),
data = en_fscores) +
geom_point() +
geom_abline(yintercept = 0, slope = 1, linetype = "dashed")
## Warning in geom_abline(yintercept = 0, slope = 1, linetype = "dashed"):
## Ignoring unknown parameters: `yintercept`
Double-checking that nouns only load onto nouns + general, verbs only load onto verbs + general, others only load onto general.
en_coefs %>%
group_by(domain) %>%
summarise(n_items = n(), mean_a1 = mean(a1), mean_a2 = mean(a2), mean_a3 = mean(a3)) %>%
kable(digits = 2, caption = "English: mean discrimination on G/S1(noun)/S2(verb) by domain.") %>%
html_table_width(rep(120, 5))
| domain | n_items | mean_a1 | mean_a2 | mean_a3 |
|---|---|---|---|---|
| noun | 364 | 1 | 1 | 0 |
| other | 214 | 1 | 0 | 0 |
| verb | 103 | 1 | 0 | 1 |
en_fscores %>%
select(response_id, ability_general, ability_noun, ability_verb) %>%
pivot_longer(-response_id, names_to = "dimension", values_to = "theta") %>%
ggplot(aes(x = theta, fill = dimension)) +
geom_histogram(alpha = 0.7, bins = 30, position = "identity") +
facet_wrap(~dimension) +
theme_classic() +
theme(legend.position = "none") +
xlab("Ability (theta)")
vi_bifactor_1pl <- fit_bifactor_variant(d_mat_vi, d_items_vi, out_dir = "models/vi", itemtype = "Rasch")
vi_bifactor_2pl <- fit_bifactor_variant(d_mat_vi, d_items_vi, out_dir = "models/vi", itemtype = "2PL")
vi_bifactor_3pl <- fit_bifactor_variant(d_mat_vi, d_items_vi, out_dir = "models/vi", itemtype = "3PL")
vi_bifactor_4pl <- fit_bifactor_variant(d_mat_vi, d_items_vi, out_dir = "models/vi", itemtype = "4PL")
load("models/vi/mod_2pl.Rds") # unidimensional baseline
mod_bifactor_1pl <- readRDS("models/vi/mod_bifactor_1pl.Rds")
mod_bifactor_2pl <- readRDS("models/vi/mod_bifactor_2pl.Rds")
mod_bifactor_3pl <- readRDS("models/vi/mod_bifactor_3pl.Rds")
mod_bifactor_4pl <- readRDS("models/vi/mod_bifactor_4pl.Rds")
Compares the bifactor structure (G + noun + verb factors) fit at each
itemtype, plus the plain unidimensional 2PL (mod_2pl) as a
baseline.
mc1_vi <- get_anova_table(mod_bifactor_1pl, mod_bifactor_2pl, c("Bifactor 1PL", "Bifactor 2PL"))
mc2_vi <- get_anova_table(mod_bifactor_1pl, mod_bifactor_3pl, c("Bifactor 1PL", "Bifactor 3PL"))
mc3_vi <- get_anova_table(mod_bifactor_1pl, mod_bifactor_4pl, c("Bifactor 1PL", "Bifactor 4PL"))
mc4_vi <- get_anova_table(mod_2pl, mod_bifactor_1pl, c("Unidim 2PL", "Bifactor 1PL"))
kable(rbind(mc1_vi, mc2_vi, mc3_vi, mc4_vi), digits = 2,
caption = "Vietnamese: pairwise nested-model comparisons.") %>%
html_table_width(c(90, 90, 90, 90, 50))
| Model | AIC | BIC | logLik | df |
|---|---|---|---|---|
| Bifactor 1PL | 62866.50 | 64906.02 | -30743.25 | NA |
| Bifactor 2PL | 62597.43 | 68098.22 | -29437.71 | 1171 |
| Bifactor 1PL | 62866.50 | 64906.02 | -30743.25 | NA |
| Bifactor 3PL | 63875.66 | 71407.11 | -29389.83 | 1858 |
| Bifactor 1PL | 62866.50 | 64906.02 | -30743.25 | NA |
| Bifactor 4PL | 65000.88 | 74562.98 | -29265.44 | 2545 |
| Unidim 2PL | 64254.78 | 68316.09 | -30753.39 | NA |
| Bifactor 1PL | 62866.50 | 64906.02 | -30743.25 | -684 |
bifactor_variants_vi <- list(`1PL` = mod_bifactor_1pl, `2PL` = mod_bifactor_2pl,
`3PL` = mod_bifactor_3pl, `4PL` = mod_bifactor_4pl,
`Unidim2PL` = mod_2pl
)
fit_table_vi <- get_fit_table(bifactor_variants_vi, names(bifactor_variants_vi))
kable(fit_table_vi, digits = 2,
caption = "Vietnamese: fit statistics across the four bifactor itemtypes.") %>%
html_table_width(c(90, 90, 90, 90, 90))
| Model | AIC | SABIC | HQ | BIC | logLik | logPost |
|---|---|---|---|---|---|---|
| 1PL | 62866.50 | 62722.81 | 63695.28 | 64906.02 | -30743.25 | NA |
| 2PL | 62597.43 | 62209.89 | 64832.73 | 68098.22 | -29437.71 | -30081.22 |
| 3PL | 63875.66 | 63345.07 | 66936.14 | 71407.11 | -29389.83 | -30021.71 |
| 4PL | 65000.88 | 64327.22 | 68886.53 | 74562.98 | -29265.44 | -29905.58 |
| Unidim2PL | 64254.78 | 63968.66 | 65905.13 | 68316.09 | -30753.39 | NA |
Selects the best-fitting bifactor variant by BIC (see the English
section above for the rationale). All of the inferential analysis below
is run on this selected model, referred to from here on as
mod_bifactor.
The best model is Bifactor 1PL (same as English).
best_variant_vi <- names(bifactor_variants_vi)[which.min(fit_table_vi$BIC)]
mod_bifactor <- bifactor_variants_vi[[best_variant_vi]]
cat("Best-fitting Vietnamese bifactor variant (by BIC):", best_variant_vi)
## Best-fitting Vietnamese bifactor variant (by BIC): 1PL
vi_bifactor_summary <- extract_bifactor_summary(mod_bifactor, d_demo_vi, d_items_vi)
vi_coefs <- vi_bifactor_summary$coefs
vi_fscores <- vi_bifactor_summary$fscores
cat("Converged:", extract.mirt(mod_bifactor, "converged"),
"| Iterations:", extract.mirt(mod_bifactor, "iterations"),
"| logLik:", round(logLik(mod_bifactor), 1))
## Converged: TRUE | Iterations: 62 | logLik: -30743.2
Investigate ability_noun and ability_verb
for spread.
summary(vi_fscores$ability_noun)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## -2.59437 -0.51041 -0.07837 -0.04878 0.35845 2.06732
summary(vi_fscores$ability_verb)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## -2.56118 -0.74693 -0.22793 -0.02973 0.55725 4.02561
sd(vi_fscores$ability_noun)
## [1] 0.7596996
sd(vi_fscores$ability_verb)
## [1] 1.053916
Same procedure as the English section above
(bootstrap_noun_ci(), defined earlier): a nonparametric 95%
bootstrap CI on ability_noun per child, from B = 1000
item-resampling replicates under the fixed, already-fitted item
parameters.
vi_noun_boot <- bootstrap_noun_ci(mod_bifactor, d_mat_vi, d_demo_vi, B = 1000,
cache_file = file.path("models/vi", paste0("noun_boot_ci_", best_variant_vi, ".Rds")))
vi_fscores <- vi_fscores %>% left_join(vi_noun_boot, by = c("row_id", "response_id"))
noun_idx_vi <- which(d_items_vi$item_kind %in% noun_kinds)
noun_raw_vi <- rowSums(d_mat_vi[, noun_idx_vi])
vi_fscores_demog <- vi_fscores %>%
left_join(d_demo_vi %>% select(row_id, response_id, age, pct_active_english, pct_active_vietnamese, production),
by = c("row_id", "response_id")) %>%
mutate(noun_raw = noun_raw_vi, floor_noun = noun_raw == 0)
summary(vi_fscores$ability_noun_boot_SD)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.1660 0.2034 0.2399 0.2599 0.2982 0.3972
summary(vi_fscores$ability_noun_CI_upper - vi_fscores$ability_noun_CI_lower)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.6578 0.7995 0.9237 1.0141 1.1816 1.5812
ability_noun and ability_verb share a
nominal prior scale but differ in empirical spread due to differential
shrinkage (see the English section above for why).
ggplot(aes(x = ability_noun, y = ability_verb),
data = vi_fscores) +
geom_point() +
geom_abline(yintercept = 0, slope = 1)
## Warning in geom_abline(yintercept = 0, slope = 1): Ignoring unknown parameters:
## `yintercept`
vi_coefs %>%
group_by(domain) %>%
summarise(n_items = n(), mean_a1 = mean(a1), mean_a2 = mean(a2), mean_a3 = mean(a3)) %>%
kable(digits = 2, caption = "Vietnamese: mean discrimination on G/S1(noun)/S2(verb) by domain.") %>%
html_table_width(rep(120, 5))
| domain | n_items | mean_a1 | mean_a2 | mean_a3 |
|---|---|---|---|---|
| noun | 384 | 1 | 1 | 0 |
| other | 200 | 1 | 0 | 0 |
| verb | 103 | 1 | 0 | 1 |
vi_fscores %>%
select(response_id, ability_general, ability_noun, ability_verb) %>%
pivot_longer(-response_id, names_to = "dimension", values_to = "theta") %>%
ggplot(aes(x = theta, fill = dimension)) +
geom_histogram(alpha = 0.7, bins = 30, position = "identity") +
facet_wrap(~dimension) +
theme_classic() +
theme(legend.position = "none") +
xlab("Ability (theta)")
row_id is a child-level identifier shared by the English
and Vietnamese surveys (unlike response_id, which is
per-survey-response), confirmed to fully overlap across the two raw data
files, and age is identical across the two surveys for the
same child. It’s used here to join each child’s independently-derived
English and Vietnamese noun-ability bootstrap CIs (Step 2 above), to ask
whether a child’s noun ability is consistent across their two
languages.
The English and Vietnamese bifactor models are fit independently, so
their ability_noun scales aren’t directly comparable and
have different spreads. So before computing overlap,
ability_noun and its CI bounds are z-scored within each
language, using that language’s own full-sample mean/SD.
For each child with both an English and a Vietnamese noun-ability
estimate, overlap_amount is the signed length of the
intersection of their two (standardized) 95% CIs:
min(upper_en, upper_vi) - max(lower_en, lower_vi). A
positive value is the width of CI overlap; a negative value is the size
of the gap between two non-overlapping intervals. overlap
is whether overlap_amount > 0.
en_mean_noun <- mean(en_fscores$ability_noun)
en_sd_noun <- sd(en_fscores$ability_noun)
vi_mean_noun <- mean(vi_fscores$ability_noun)
vi_sd_noun <- sd(vi_fscores$ability_noun)
cross_lang <- inner_join(
en_fscores_demog %>%
select(row_id, ability_noun_en = ability_noun,
CI_lower_en = ability_noun_CI_lower, CI_upper_en = ability_noun_CI_upper,
floor_noun_en = floor_noun, production_en = production,
age, pct_active_english, pct_active_vietnamese),
vi_fscores_demog %>%
select(row_id, ability_noun_vi = ability_noun,
CI_lower_vi = ability_noun_CI_lower, CI_upper_vi = ability_noun_CI_upper,
floor_noun_vi = floor_noun, production_vi = production),
by = "row_id"
) %>%
mutate(
ability_noun_en = (ability_noun_en - en_mean_noun) / en_sd_noun,
CI_lower_en = (CI_lower_en - en_mean_noun) / en_sd_noun,
CI_upper_en = (CI_upper_en - en_mean_noun) / en_sd_noun,
ability_noun_vi = (ability_noun_vi - vi_mean_noun) / vi_sd_noun,
CI_lower_vi = (CI_lower_vi - vi_mean_noun) / vi_sd_noun,
CI_upper_vi = (CI_upper_vi - vi_mean_noun) / vi_sd_noun,
overlap_amount = pmin(CI_upper_en, CI_upper_vi) - pmax(CI_lower_en, CI_lower_vi),
overlap = overlap_amount > 0,
gap_direction = case_when(
overlap_amount >= 0 ~ "Overlap",
ability_noun_en > ability_noun_vi ~ "Gap: EN > VI",
TRUE ~ "Gap: VI > EN"
)
)
cat(round(100 * mean(cross_lang$overlap), 1), "% of children have overlapping EN/VI 95% CIs on ability_noun\n")
## 67.6 % of children have overlapping EN/VI 95% CIs on ability_noun
summary(cross_lang$overlap_amount)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## -4.3944 -0.1779 0.3336 0.2588 0.8983 1.9681
Gap cases (overlap_amount < 0, non-overlapping CIs)
are colored by which language is higher, overlaps are greys.
ggplot(cross_lang, aes(x = overlap_amount, fill = gap_direction)) +
geom_histogram(bins = 30) +
geom_vline(xintercept = 0, linetype = "dashed") +
theme_classic() +
scale_fill_manual(values = c(Overlap = "grey70", `Gap: EN > VI` = "#F8766D", `Gap: VI > EN` = "#00BFC4")) +
labs(x = "EN-VI noun-ability CI overlap (ability z-scored)", fill = NULL)
42 of 142 children produced zero total words in at least one language (all English non-producers). The estimates for these children might not be reliable. Results when they are excluded below.
cross_lang_nonzero <- cross_lang %>% filter(production_en > 0, production_vi > 0)
cat(round(100 * mean(cross_lang_nonzero$overlap), 1),
"% of children have overlapping EN/VI 95% CIs on ability_noun (excluding zero-producers)\n")
## 81 % of children have overlapping EN/VI 95% CIs on ability_noun (excluding zero-producers)
summary(cross_lang_nonzero$overlap_amount)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## -4.3944 0.2810 0.7227 0.5464 1.0067 1.9681
ggplot(cross_lang_nonzero, aes(x = overlap_amount, fill = gap_direction)) +
geom_histogram(bins = 30) +
geom_vline(xintercept = 0, linetype = "dashed") +
theme_classic() +
scale_fill_manual(values = c(Overlap = "grey70", `Gap: EN > VI` = "#F8766D", `Gap: VI > EN` = "#00BFC4")) +
labs(x = "EN-VI noun-ability CI overlap (ability z-scored)", fill = NULL,
title = "Excluding children who produced 0 words in either language")
One row per child, showing both language-specific 95% CIs on
ability_noun side by side. Children are sorted by
overlap_amount (ascending), so the least
overlapping/most-gapped pairs are at the bottom and the most-overlapping
pairs are at the top. Floor-score children (0 of that language’s noun
items produced) are marked with a star instead of a dot. These are the
cases where the estimates (and bootstrap CIs) are not trustworthy. These
kids also make up most of the cases where CIs do not include the
estimates, because for these kids most of the sampled replicates
would
cross_lang_plot_df <- cross_lang %>%
arrange(overlap_amount) %>%
mutate(child_rank = row_number()) %>%
select(child_rank, row_id, ability_noun_en, CI_lower_en, CI_upper_en, floor_noun_en,
ability_noun_vi, CI_lower_vi, CI_upper_vi, floor_noun_vi) %>%
pivot_longer(
cols = -c(child_rank, row_id),
names_to = c(".value", "lang"),
names_pattern = "(ability_noun|CI_lower|CI_upper|floor_noun)_(en|vi)"
) %>%
mutate(
lang = recode(lang, en = "English", vi = "Vietnamese"),
y_pos = child_rank + ifelse(lang == "English", 0.2, -0.2),
floor_noun = factor(floor_noun, levels = c(FALSE, TRUE), labels = c("Not floor", "Floor score (0 nouns)"))
)
ggplot(cross_lang_plot_df, aes(y = y_pos, x = ability_noun, color = lang)) +
geom_errorbar(aes(xmin = CI_lower, xmax = CI_upper), orientation = "y", width = 0, linewidth = 0.3) +
geom_point(aes(shape = floor_noun, size = floor_noun)) +
scale_shape_manual(values = c(`Not floor` = 16, `Floor score (0 nouns)` = 8)) +
scale_size_manual(values = c(`Not floor` = 0.6, `Floor score (0 nouns)` = 1.6)) +
geom_vline(xintercept = 0, linetype = "dashed", color = "grey50") +
theme_classic() +
theme(axis.text.y = element_blank(), axis.ticks.y = element_blank()) +
labs(x = "z(ability_noun), within-language (95% bootstrap CI)",
y = "Children (sorted by EN-VI CI overlap, ascending)",
color = "Language", shape = "Score type", size = "Score type",
title = "Per-child 95% CI on noun ability: English vs. Vietnamese")
pct_active_english and
pct_active_vietnamese are substantially negatively
correlated for these bilingual children (most report only these two
active languages, so more of one tends to mean less of the other) – see
the correlation below. To avoid collinearity in one regression, we lead
with pct_active_vietnamese (the heritage-language exposure)
as the primary predictor; swap in pct_active_english to
check the mirror-image model.
cat("cor(pct_active_english, pct_active_vietnamese):",
round(cor(cross_lang$pct_active_english, cross_lang$pct_active_vietnamese, use = "complete.obs"), 3))
## cor(pct_active_english, pct_active_vietnamese): -0.639
Older kids are more likely to show divergence between noun dominance in the two languages. I added pct_active_vietnamese as a way to “control” for language exposure, but not sure if that’s the correct approach.
summary(lm(overlap_amount ~ age + pct_active_vietnamese, data = cross_lang))
##
## Call:
## lm(formula = overlap_amount ~ age + pct_active_vietnamese, data = cross_lang)
##
## Residuals:
## Min 1Q Median 3Q Max
## -4.4624 -0.5035 0.1288 0.6184 1.5552
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 1.354982 0.366589 3.696 0.000314 ***
## age -0.039500 0.012903 -3.061 0.002646 **
## pct_active_vietnamese -0.002079 0.002318 -0.897 0.371207
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.9032 on 139 degrees of freedom
## Multiple R-squared: 0.06523, Adjusted R-squared: 0.05178
## F-statistic: 4.85 on 2 and 139 DF, p-value: 0.009206
summary(lm(overlap_amount ~ age + pct_active_vietnamese,
data = cross_lang |> filter(production_en > 0, production_vi > 0)))
##
## Call:
## lm(formula = overlap_amount ~ age + pct_active_vietnamese, data = filter(cross_lang,
## production_en > 0, production_vi > 0))
##
## Residuals:
## Min 1Q Median 3Q Max
## -4.6242 -0.3224 0.1345 0.5428 1.3331
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 1.930303 0.418965 4.607 1.24e-05 ***
## age -0.053725 0.014085 -3.814 0.00024 ***
## pct_active_vietnamese -0.001166 0.003188 -0.366 0.71542
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.8393 on 97 degrees of freedom
## Multiple R-squared: 0.1311, Adjusted R-squared: 0.1132
## F-statistic: 7.32 on 2 and 97 DF, p-value: 0.001095
ggplot(cross_lang, aes(x = age, y = overlap_amount)) +
geom_point(aes(color = gap_direction), alpha = 0.6) +
geom_smooth(method = "lm", se = TRUE) +
geom_hline(yintercept = 0, linetype = "dashed") +
theme_classic() +
scale_color_manual(values = c(Overlap = "grey70", `Gap: EN > VI` = "#F8766D", `Gap: VI > EN` = "#00BFC4")) +
labs(x = "Age (months)", y = "EN-VI noun-ability CI overlap", color = NULL)
## `geom_smooth()` using formula = 'y ~ x'
ggplot(cross_lang |> filter(production_en > 0, production_vi > 0),
aes(x = age, y = overlap_amount)) +
geom_point(aes(color = gap_direction), alpha = 0.6) +
geom_smooth(method = "lm", se = TRUE) +
geom_hline(yintercept = 0, linetype = "dashed") +
theme_classic() +
scale_color_manual(values = c(Overlap = "grey70", `Gap: EN > VI` = "#F8766D", `Gap: VI > EN` = "#00BFC4")) +
labs(x = "Age (months)", y = "EN-VI noun-ability CI overlap (SD units)", color = NULL,
title = "Excluding children who produced 0 words in either language")
## `geom_smooth()` using formula = 'y ~ x'
Noun ability in Vietnamese tracks with % active in Vietnamese (makes
sense!) But, kids who have an even split seem to have both overlap 95%
CI and gaps. Some kids whose parents reported high Viet use also show EN
> VI gap.
ggplot(cross_lang, aes(x = pct_active_vietnamese, y = ability_noun_vi)) +
geom_point(aes(color = gap_direction), alpha = 0.6) +
geom_smooth(method = "lm", se = TRUE) +
theme_classic() +
scale_color_manual(values = c(Overlap = "grey70", `Gap: EN > VI` = "#F8766D", `Gap: VI > EN` = "#00BFC4")) +
labs(x = "% active Vietnamese exposure", y = "Ability Noun VI", color = NULL)
## `geom_smooth()` using formula = 'y ~ x'
ggplot(cross_lang |> filter(production_en > 0, production_vi > 0),
aes(x = pct_active_vietnamese, y = ability_noun_vi)) +
geom_point(aes(color = gap_direction), alpha = 0.6) +
geom_smooth(method = "lm", se = TRUE) +
theme_classic() +
scale_color_manual(values = c(Overlap = "grey70", `Gap: EN > VI` = "#F8766D", `Gap: VI > EN` = "#00BFC4")) +
labs(x = "% active Vietnamese exposure", y = "Ability Noun VI", color = NULL,
title = "Excluding children who produced 0 words in either language")
## `geom_smooth()` using formula = 'y ~ x'
ggplot(cross_lang,
aes(x = pct_active_vietnamese, y = overlap_amount)) +
geom_point(aes(color = gap_direction), alpha = 0.6) +
geom_smooth(se = TRUE) +
geom_hline(yintercept = 0, linetype = "dashed") +
theme_classic() +
scale_color_manual(values = c(Overlap = "grey70", `Gap: EN > VI` = "#F8766D", `Gap: VI > EN` = "#00BFC4")) +
labs(x = "% active Vietnamese exposure", y = "EN-VI noun-ability CI overlap", color = NULL)
## `geom_smooth()` using method = 'loess' and formula = 'y ~ x'
ggplot(cross_lang |> filter(production_en > 0, production_vi > 0),
aes(x = pct_active_vietnamese, y = overlap_amount)) +
geom_point(aes(color = gap_direction), alpha = 0.6) +
geom_smooth(se = TRUE) +
geom_hline(yintercept = 0, linetype = "dashed") +
theme_classic() +
scale_color_manual(values = c(Overlap = "grey70", `Gap: EN > VI` = "#F8766D", `Gap: VI > EN` = "#00BFC4")) +
labs(x = "% active Vietnamese exposure", y = "EN-VI noun-ability CI overlap", color = NULL,
title = "Excluding children who produced 0 words in either language")
## `geom_smooth()` using method = 'loess' and formula = 'y ~ x'
The majority of kids (67.6-81%, depending on whether we count the kids who do not produce words in one language) show “noun-dominance” that are consistent between English and Vietnamese. This would be consistent with the hypothesis that noun/verb dominance are underpinned by conceptual differences.
Another possibility is that kids tend to get exposed to translation equivalents. We would need to establish that their language input in the languages are actually different, but I think this is unlikely…
Questions:
How do we test whether language exposure determine overlap or not?
For kids who are more “balanced” in language exposure, what determines whether they have better ability in English vs. Vietnamese?