Among the 142 children with both an English and a Vietnamese CDI,
data/participant_info.csv provides a researcher-coded
“bi”/“mono” classification per child
(group column) – this is the authoritative grouping used
throughout this analysis, rather than a production-based proxy. An
earlier version of this analysis defined “Monolingual” as “produced zero
English words on this survey” (42/142 children), since the actual group
label wasn’t yet joined in; that production-based proxy is kept below
purely as a cross-check against the authoritative
label, and any disagreements between the two are reported
explicitly rather than papered over.
Question: are there specific Vietnamese words that are differentially likely to be produced by one group vs. the other – i.e. words that are “representative” of (more diagnostic of) monolingual vs. bilingual production, beyond what’s just explained by chance or by age?
Caveat on what “monolingual” means here: we don’t
have participant_info.csv’s exact coding criterion in hand
(e.g. whether “mono” reflects parent-reported language exposure,
household language, or something else) – it’s used here as the intended
ground-truth group label precisely because it should capture more than
this one survey’s production snapshot, but the precise operational
definition behind it should be confirmed against the original study
documentation before this label is reported in a paper. We check below
whether this grouping is confounded with age (it isn’t, much) and
control for age in a secondary check regardless.
lang_config <- list(
en = list(data_file = "data/EnglishAmericanWS_Bui_data_redact.csv", fields_file = "data/EnglishAmericanWS_Bui_fields.csv", prefix = "cat_eng_"),
vi = list(data_file = "data/VietnameseWS_Bui_data.csv", fields_file = "data/VietnameseWS_Bui_fields.csv", prefix = "cat_vie_")
)
# Mirrors load_lang_data() in 03_bifactor_noun_verb.Rmd / 05_item_difficulty_crosslang.Rmd:
# d_mat's column names are implicitly sanitized to match d_items$definition via
# data.frame()'s default check.names = TRUE (e.g. "french fries" -> "french.fries"),
# which is why we build d_mat this way rather than pivoting the raw tibble directly
# (see 05_item_difficulty_crosslang.Rmd's compute_item_aoa()/compute_category_trajectory()
# fix for what goes wrong if you don't).
load_lang_data <- function(lang) {
cfg <- lang_config[[lang]]
raw <- read_csv(cfg$data_file, show_col_types = FALSE)
d_demo <- raw %>% select(row_id, response_id, age)
d_wide <- raw %>%
select(response_id, starts_with(cfg$prefix)) %>%
mutate(across(everything(), ~replace_na(.x, 0)))
d_mat <- d_wide %>% data.frame() %>% select(-response_id) %>% data.matrix()
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, "^.*?___")
) %>%
select(definition, item_kind, item_definition)
stopifnot(all(colnames(d_mat) == d_items$definition))
list(d_mat = d_mat, d_demo = d_demo, d_items = d_items)
}
en_data <- load_lang_data("en")
vi_data <- load_lang_data("vi")
row_id is the child-level identifier shared by the
English and Vietnamese surveys (established in
03_bifactor_noun_verb.Rmd), confirmed below to fully
overlap (142 children in both) with identical age across
surveys, so we can attach each child’s English production status onto
their Vietnamese responses positionally.
participant_info.csv’s own child identifier is
ResponseId, which we verified separately matches
response_id in both the English and Vietnamese
data files for all 142 children (not row_id, which doesn’t
appear in participant_info.csv at all) – so that join uses
response_id instead.
production_en <- tibble(row_id = en_data$d_demo$row_id, production_en = rowSums(en_data$d_mat))
participant_info <- read_csv("data/participant_info.csv", show_col_types = FALSE) %>%
transmute(
response_id = ResponseId,
group_reported = factor(recode(group, bi = "Bilingual", mono = "Monolingual"),
levels = c("Bilingual", "Monolingual"))
)
## New names:
## • `` -> `...1`
vi_demo <- vi_data$d_demo %>%
left_join(production_en, by = "row_id") %>%
left_join(participant_info, by = "response_id")
stopifnot(!anyNA(vi_demo$production_en)) # every Vietnamese respondent also has an English response
stopifnot(!anyNA(vi_demo$group_reported)) # every Vietnamese respondent also has a participant_info row
stopifnot(nrow(vi_demo) == nrow(vi_data$d_demo)) # neither left_join duplicated/dropped any rows
vi_demo <- vi_demo %>%
mutate(
group_production = factor(if_else(production_en == 0, "Monolingual", "Bilingual"),
levels = c("Bilingual", "Monolingual")),
group = group_reported, # authoritative grouping used throughout the rest of this analysis
age_c = age - mean(age, na.rm = TRUE)
)
table(vi_demo$group)
##
## Bilingual Monolingual
## 101 41
For each Vietnamese word, we compare production rate between the two groups two ways:
p.adjust(method = "BH")) across all ~680 items tested.
log_or > 0 means the word is more likely to be
produced by Monolingual (Vietnamese-only) children;
log_or < 0 means more likely for Bilingual
children.glm(produces ~ group + age_c, family = binomial) per word,
reported alongside so a word whose raw association disappears (or flips
sign) once age is controlled for can be flagged as likely age-driven
rather than genuinely group-distinctive. This can fail to converge or
blow up (quasi- complete separation) for rare words even with age
included – those are set to NA rather than trusted, the
same convention used for non-convergent per-item fits elsewhere in this
project (e.g. compute_item_aoa() in
05_item_difficulty_crosslang.Rmd).compare_mono_bilingual <- function(d_mat, group, age_c, d_items) {
n_items <- ncol(d_mat)
defs <- colnames(d_mat)
map_dfr(seq_len(n_items), function(j) {
y <- d_mat[, j]
tab <- table(group, y)
if (!("0" %in% colnames(tab))) tab <- cbind(`0` = c(0, 0), tab)
if (!("1" %in% colnames(tab))) tab <- cbind(tab, `1` = c(0, 0))
tab <- tab[, c("0", "1")]
a <- tab["Bilingual", "1"] + 0.5; b <- tab["Bilingual", "0"] + 0.5
cc <- tab["Monolingual", "1"] + 0.5; d <- tab["Monolingual", "0"] + 0.5
log_or <- log((cc / d) / (a / b))
se_log_or <- sqrt(1 / a + 1 / b + 1 / cc + 1 / d)
fisher_p <- tryCatch(fisher.test(tab)$p.value, error = function(e) NA_real_)
n_bi <- sum(group == "Bilingual"); n_mono <- sum(group == "Monolingual")
p_bi <- mean(y[group == "Bilingual"]); p_mono <- mean(y[group == "Monolingual"])
glm_or <- NA_real_; glm_se <- NA_real_
if (length(unique(y)) > 1) {
fit <- tryCatch(glm(y ~ group + age_c, family = binomial), error = function(e) NULL)
if (!is.null(fit) && fit$converged) {
cf <- summary(fit)$coefficients
if ("groupMonolingual" %in% rownames(cf)) {
est <- cf["groupMonolingual", "Estimate"]; se <- cf["groupMonolingual", "Std. Error"]
if (se <= 5) { glm_or <- est; glm_se <- se } # else: likely quasi-separation, leave NA
}
}
}
tibble(definition = defs[j], n_bilingual = n_bi, n_monolingual = n_mono,
p_bilingual = p_bi, p_monolingual = p_mono, diff = p_mono - p_bi,
log_or = log_or, se_log_or = se_log_or, fisher_p = fisher_p,
glm_or_age_adj = glm_or, glm_se_age_adj = glm_se)
}) %>%
mutate(p_fdr = p.adjust(fisher_p, method = "BH")) %>%
left_join(d_items, by = "definition")
}
vi_word_group_diff <- compare_mono_bilingual(vi_data$d_mat, vi_demo$group, vi_demo$age_c, vi_data$d_items)
Top 20 words by log-odds ratio (Haldane-Anscombe corrected): most over-represented among children who produce zero English words, relative to bilingual producers.
vi_word_group_diff %>%
arrange(desc(log_or)) %>%
slice_head(n = 20) %>%
select(item_definition, item_kind, n_bilingual, n_monolingual, p_bilingual, p_monolingual,
log_or, fisher_p, p_fdr, glm_or_age_adj) %>%
kable(digits = c(0, 0, 0, 0, 2, 2, 2, 3, 3, 2),
caption = "Top 20 words over-represented among Vietnamese-only (Monolingual) producers.") %>%
html_table_width(c(140, 80, 60, 60, 70, 70, 60, 60, 60, 90))
| item_definition | item_kind | n_bilingual | n_monolingual | p_bilingual | p_monolingual | log_or | fisher_p | p_fdr | glm_or_age_adj |
|---|---|---|---|---|---|---|---|---|---|
| sân cát | outdoor | 101 | 41 | 0.02 | 0.17 | 2.16 | 0.002 | 0.064 | 2.55 |
| chúng tôi | pronouns | 101 | 41 | 0.02 | 0.17 | 2.16 | 0.002 | 0.064 | 2.55 |
| cách xa | locations | 101 | 41 | 0.05 | 0.27 | 1.89 | 0.001 | 0.047 | 1.98 |
| của anh ấy | pronouns | 101 | 41 | 0.07 | 0.29 | 1.68 | 0.001 | 0.047 | 1.80 |
| nệm | household | 101 | 41 | 0.14 | 0.46 | 1.65 | 0.000 | 0.047 | 1.94 |
| động vật | animals | 101 | 41 | 0.03 | 0.15 | 1.64 | 0.017 | 0.117 | 1.84 |
| cũng thế | quantifiers | 101 | 41 | 0.03 | 0.15 | 1.64 | 0.017 | 0.117 | 1.97 |
| xẻng | outdoor | 101 | 41 | 0.06 | 0.24 | 1.59 | 0.003 | 0.072 | 1.67 |
| hạt trang trí | clothing | 101 | 41 | 0.02 | 0.10 | 1.56 | 0.058 | 0.203 | 1.67 |
| tóe | action_words | 101 | 41 | 0.04 | 0.17 | 1.55 | 0.014 | 0.110 | 1.61 |
| hoa | outdoor | 101 | 41 | 0.43 | 0.78 | 1.53 | 0.000 | 0.047 | 1.69 |
| của chúng ta | pronouns | 101 | 41 | 0.05 | 0.20 | 1.49 | 0.011 | 0.104 | 1.60 |
| tất đầu gối | clothing | 101 | 41 | 0.03 | 0.12 | 1.44 | 0.045 | 0.175 | 1.55 |
| thế và | connecting_words | 101 | 41 | 0.03 | 0.12 | 1.44 | 0.045 | 0.175 | 1.54 |
| bà | people | 101 | 41 | 0.79 | 0.95 | 1.44 | 0.023 | 0.135 | 1.63 |
| tạm biệt | games_routines | 101 | 41 | 0.39 | 0.73 | 1.43 | 0.000 | 0.047 | 1.54 |
| hươu | animals | 101 | 41 | 0.16 | 0.44 | 1.41 | 0.001 | 0.047 | 1.54 |
| gà trống | animals | 101 | 41 | 0.18 | 0.46 | 1.36 | 0.001 | 0.047 | 1.45 |
| cổ | body_parts | 101 | 41 | 0.38 | 0.71 | 1.36 | 0.000 | 0.047 | 1.50 |
| gấu | animals | 101 | 41 | 0.41 | 0.73 | 1.35 | 0.001 | 0.047 | 1.53 |
Top 20 words by log-odds ratio, in the opposite direction: most over-represented among children who also produce English words.
vi_word_group_diff %>%
arrange(log_or) %>%
slice_head(n = 20) %>%
select(item_definition, item_kind, n_bilingual, n_monolingual, p_bilingual, p_monolingual,
log_or, fisher_p, p_fdr, glm_or_age_adj) %>%
kable(digits = c(0, 0, 0, 0, 2, 2, 2, 3, 3, 2),
caption = "Top 20 words over-represented among Bilingual producers.") %>%
html_table_width(c(140, 80, 60, 60, 70, 70, 60, 60, 60, 90))
| item_definition | item_kind | n_bilingual | n_monolingual | p_bilingual | p_monolingual | log_or | fisher_p | p_fdr | glm_or_age_adj |
|---|---|---|---|---|---|---|---|---|---|
| bắp rang bơ | food_drink | 101 | 41 | 0.15 | 0.05 | -1.04 | 0.152 | 0.313 | -1.27 |
| quần áo mùa đông | clothing | 101 | 41 | 0.11 | 0.05 | -0.70 | 0.348 | 0.513 | -0.91 |
| thạch Jello | food_drink | 101 | 41 | 0.14 | 0.07 | -0.60 | 0.395 | 0.557 | -0.75 |
| pizza | food_drink | 101 | 41 | 0.47 | 0.34 | -0.50 | 0.195 | 0.365 | -0.65 |
| đậu Hà Lan | food_drink | 101 | 41 | 0.09 | 0.05 | -0.48 | 0.511 | 0.653 | -0.67 |
| quần đùi | clothing | 101 | 41 | 0.12 | 0.07 | -0.43 | 0.554 | 0.680 | -0.56 |
| áo ngủ | clothing | 101 | 41 | 0.24 | 0.17 | -0.37 | 0.502 | 0.648 | -0.48 |
| bánh mì gối | food_drink | 101 | 41 | 0.08 | 0.05 | -0.36 | 0.724 | 0.830 | -0.53 |
| be be | sounds | 101 | 41 | 0.48 | 0.39 | -0.34 | 0.457 | 0.610 | -0.43 |
| người đưa thư | people | 101 | 41 | 0.11 | 0.07 | -0.33 | 0.757 | 0.853 | -0.49 |
| khối | toys | 101 | 41 | 0.11 | 0.07 | -0.33 | 0.757 | 0.853 | -0.48 |
| phố | places | 101 | 41 | 0.14 | 0.10 | -0.32 | 0.589 | 0.713 | -0.41 |
| bây giờ | time_words | 101 | 41 | 0.23 | 0.17 | -0.32 | 0.505 | 0.649 | -0.41 |
| ngày | time_words | 101 | 41 | 0.17 | 0.12 | -0.32 | 0.613 | 0.737 | -0.41 |
| xe | vehicles | 101 | 41 | 0.72 | 0.66 | -0.31 | 0.543 | 0.673 | -0.33 |
| quạc quạc | sounds | 101 | 41 | 0.74 | 0.68 | -0.30 | 0.535 | 0.668 | -0.41 |
| meo | sounds | 101 | 41 | 0.82 | 0.78 | -0.28 | 0.638 | 0.757 | -0.35 |
| một cái | quantifiers | 101 | 41 | 0.25 | 0.20 | -0.27 | 0.662 | 0.777 | -0.34 |
| áo len | clothing | 101 | 41 | 0.22 | 0.17 | -0.26 | 0.648 | 0.765 | -0.35 |
| sân sau | outdoor | 101 | 41 | 0.13 | 0.10 | -0.24 | 0.778 | 0.870 | -0.33 |
ggplot(vi_word_group_diff, aes(x = log_or, y = -log10(fisher_p), color = p_fdr < 0.05)) +
geom_point(alpha = 0.6) +
geom_vline(xintercept = 0, linetype = "dashed", color = "grey50") +
theme_classic() +
scale_color_manual(values = c(`TRUE` = "#D55E00", `FALSE` = "grey70")) +
xlab("Log-odds ratio (Monolingual vs. Bilingual, Haldane-Anscombe corrected)") +
ylab(expression(-log[10]("Fisher's exact p-value"))) +
labs(color = "FDR < .05",
title = "Words distinguishing Vietnamese-only vs. bilingual producers")
How many words survive FDR correction at all, in each direction:
vi_word_group_diff %>%
filter(p_fdr < 0.05) %>%
mutate(direction = if_else(log_or > 0, "More likely: Monolingual", "More likely: Bilingual")) %>%
count(direction) %>%
kable(caption = "Number of words significant at FDR < .05, by direction.") %>%
html_table_width(c(200, 80))
| direction | n |
|---|---|
| More likely: Monolingual | 13 |
Since all 13 FDR-significant words favor the Monolingual direction, the raw production-rate gap for each is shown directly below (a dumbbell plot: Bilingual rate vs. Monolingual rate per word, ordered by gap size) – this is the same 13 words as the top rows of the “words representative of Vietnamese-only producers” table above, but only the FDR-significant subset, and as a plot rather than a table.
sig_words_plot_df <- vi_word_group_diff %>%
filter(p_fdr < 0.05, log_or > 0) %>%
mutate(item_definition = fct_reorder(item_definition, diff))
ggplot(sig_words_plot_df, aes(y = item_definition)) +
geom_segment(aes(x = p_bilingual, xend = p_monolingual, y = item_definition, yend = item_definition),
color = "grey70", linewidth = 0.6) +
geom_point(aes(x = p_bilingual, color = "Bilingual"), size = 2.5) +
geom_point(aes(x = p_monolingual, color = "Monolingual"), size = 2.5) +
theme_classic() +
xlab("Proportion of children producing") + ylab(NULL) +
labs(color = NULL, title = "Production rate: Bilingual vs. Vietnamese-only, FDR-significant words")
vi_word_group_diff %>%
filter(p_fdr < 0.05) %>%
mutate(direction = if_else(log_or > 0, "More likely: Monolingual", "More likely: Bilingual")) %>%
count(item_kind, direction) %>%
pivot_wider(names_from = direction, values_from = n, values_fill = 0) %>%
kable(caption = "Count of FDR-significant words by category and direction.") %>%
html_table_width(c(140, 100, 100))
| item_kind | More likely: Monolingual |
|---|---|
| action_words | 1 |
| animals | 4 |
| body_parts | 1 |
| food_drink | 1 |
| games_routines | 1 |
| household | 2 |
| locations | 1 |
| outdoor | 1 |
| pronouns | 1 |
vi_word_group_diff %>%
filter(p_fdr < 0.05, log_or > 0) %>%
count(item_kind) %>%
mutate(item_kind = fct_reorder(item_kind, n)) %>%
ggplot(aes(x = n, y = item_kind)) +
geom_col(fill = "#D55E00") +
theme_classic() +
xlab("Number of FDR-significant words") + ylab(NULL) +
labs(title = "Where the 13 Monolingual-favoring words concentrate, by category")
At FDR < .05, 13 words are significantly over-represented among Vietnamese-only producers, and zero words are significantly over-represented among bilingual producers (the strongest bilingual-leaning candidate, “bắp rang bơ”/popcorn, only reaches p = .15, q = .31). This asymmetry is itself worth noting rather than a null result to skip past.
A look at the 13 Monolingual-favoring words is informative: many are
ordinary, non-culturally-specific concepts a bilingual 2-3-year-old
plausibly also knows – animals (gấu/bear,
hươu/deer, gà trống/rooster,
khỉ/monkey), household items (nệm/mattress),
food (thịt/meat), a body part (cổ/neck). These
don’t look like words that are specifically absent from a
bilingual household’s Vietnamese input. A more likely explanation: the
CDI only records production in the surveyed language. A
bilingual child who knows the concept “bear” but has started saying
“bear” in English rather than “gấu” in Vietnamese would show up as a
Vietnamese non-producer for that item, even though they’re not missing
the underlying concept – only the Vietnamese-specific expression of it.
Under this reading, these 13 words could be less “words distinctive of
monolingual family environments” and more “words where bilingual
children have already shifted productive labeling to English” – a
substantively different (and, if true, more interesting) claim than the
original “representative vocabulary” framing. The check below tests this
directly.
For each of the 13 significant Monolingual-favoring words, using
data/EngVieConceptMatch.xlsx (a researcher-coded EN-VI
concept-match table, more complete than the earlier hand-filled
crosswalk): among the bilingual children who do
not produce that Vietnamese word, what fraction
do produce its English translation equivalent? A high
fraction supports the language-shift account (they know it, just in
English); a low fraction supports a genuine vocabulary gap (they don’t
produce it in either language).
# EngVieConceptMatch.xlsx's `viet` column already matches vi_data$d_items$definition
# exactly (both are make.names()-sanitized the same way -- verified directly). Its
# `MB.eng.matched` column is the researcher-verified English match (as opposed to
# `eng.unmatched_1/2`, rougher candidate guesses) but has two quirks worth handling
# rather than ignoring: (1) "no match found" is coded as the *literal string* "NA",
# not a true missing value, so it must be converted explicitly; (2) a handful of
# Vietnamese words have multiple plausible English matches recorded as a single
# semicolon-separated string (e.g. "lấy" -> "take;get") -- these are kept as a list of
# candidates, and a child is counted as producing "the English equivalent" if they
# produce ANY one of them.
concept_match <- read_excel("data/EngVieConceptMatch.xlsx", sheet = "Sheet1") %>%
transmute(
vi_definition = viet,
en_candidates = str_split(na_if(MB.eng.matched, "NA"), ";")
)
## New names:
## • `` -> `...1`
## • `` -> `...6`
## • `` -> `...7`
sig_mono_words <- vi_word_group_diff %>%
filter(p_fdr < 0.05, log_or > 0) %>%
select(definition, item_definition, item_kind, log_or, p_fdr)
# Only candidate strings that actually exist as English items survive (2 of 673
# candidate strings across the full 687-item concept-match table are typos that don't
# match any real column, e.g. a missing "___" separator -- checked directly against
# en_data$d_items$definition); a word whose only candidate(s) fail this check is
# treated the same as having no usable match at all.
sig_mono_crosswalk <- sig_mono_words %>%
left_join(concept_match, by = c("definition" = "vi_definition")) %>%
mutate(en_candidates = map(en_candidates, ~ intersect(.x, en_data$d_items$definition)),
n_en_candidates = lengths(en_candidates))
cat(nrow(sig_mono_words), "significant Monolingual-favoring words;",
sum(sig_mono_crosswalk$n_en_candidates > 0), "have a usable translation match in EngVieConceptMatch.xlsx.\n")
## 13 significant Monolingual-favoring words; 11 have a usable translation match in EngVieConceptMatch.xlsx.
# Row-position alignment (vi_demo/vi_data$d_mat, en_data$d_demo/en_data$d_mat) follows
# the same invariant used throughout this project's Rmds (see load_lang_data() above).
vi_mat_by_row <- as_tibble(vi_data$d_mat) %>% mutate(row_id = vi_demo$row_id, group = vi_demo$group)
en_mat_by_row <- as_tibble(en_data$d_mat) %>% mutate(row_id = en_data$d_demo$row_id)
# A child counts as producing "the English equivalent" if they produce any one of
# (possibly several) candidate English items for that Vietnamese word.
check_language_shift <- function(vi_col, en_cols) {
bi_non_producers <- vi_mat_by_row %>% filter(group == "Bilingual", .data[[vi_col]] == 0) %>% pull(row_id)
en_sub <- en_mat_by_row %>% filter(row_id %in% bi_non_producers) %>% select(all_of(en_cols))
produced_any <- if (ncol(en_sub) == 1) en_sub[[1]] == 1 else apply(en_sub == 1, 1, any)
tibble(
n_bilingual_non_producers = length(bi_non_producers),
n_produce_en_equivalent = sum(produced_any),
pct_produce_en_equivalent = round(100 * mean(produced_any), 1)
)
}
language_shift_results <- sig_mono_crosswalk %>%
filter(n_en_candidates > 0) %>%
mutate(
en_definition = map_chr(en_candidates, ~ paste(str_remove(.x, "^.*?___"), collapse = " / ")),
check = map2(definition, en_candidates, check_language_shift)
) %>%
unnest(check) %>%
mutate(account = if_else(pct_produce_en_equivalent >= 50, "Language shift", "Vocabulary gap"))
language_shift_results %>%
select(item_definition, en_definition, item_kind, n_bilingual_non_producers,
n_produce_en_equivalent, pct_produce_en_equivalent, account) %>%
arrange(desc(pct_produce_en_equivalent)) %>%
kable(digits = 1, caption = paste0(
"Among bilingual non-producers of each Vietnamese word, % who produce its English equivalent ",
"(>=50% = language-shift account favored).")) %>%
html_table_width(c(120, 130, 80, 110, 110, 110, 100)) %>%
row_spec(which(language_shift_results$account == "Language shift"), background = "#FFF3CD")
| item_definition | en_definition | item_kind | n_bilingual_non_producers | n_produce_en_equivalent | pct_produce_en_equivalent | account |
|---|---|---|---|---|---|---|
| tạm biệt | bye | games_routines | 62 | 49 | 79.0 | Language shift |
| hoa | flower | outdoor | 58 | 21 | 36.2 | Vocabulary gap |
| khỉ | monkey | animals | 62 | 21 | 33.9 | Vocabulary gap |
| gấu | bear | animals | 60 | 20 | 33.3 | Vocabulary gap |
| hươu | deer | animals | 85 | 16 | 18.8 | Vocabulary gap |
| dậy | wake | action_words | 74 | 12 | 16.2 | Vocabulary gap |
| gà trống | rooster | animals | 83 | 12 | 14.5 | Vocabulary gap |
| tiền | money | household | 75 | 9 | 12.0 | Vocabulary gap |
| của anh ấy | his | pronouns | 94 | 9 | 9.6 | Vocabulary gap |
| thịt | meat | food_drink | 65 | 6 | 9.2 | Vocabulary gap |
| cách xa | away | locations | 96 | 8 | 8.3 | Vocabulary gap |
language_shift_results %>%
mutate(item_definition = fct_reorder(item_definition, pct_produce_en_equivalent)) %>%
ggplot(aes(x = pct_produce_en_equivalent, y = item_definition, fill = account)) +
geom_col() +
geom_vline(xintercept = 50, linetype = "dashed", color = "grey30") +
theme_classic() +
scale_fill_manual(values = c(`Language shift` = "#E69F00", `Vocabulary gap` = "grey60")) +
xlab("% of bilingual non-producers who DO produce the English equivalent") + ylab(NULL) +
labs(fill = NULL, title = "Testing the language-shift account, word by word")
language_shift_results %>%
count(account) %>%
kable(caption = "Language-shift vs. vocabulary-gap account, tallied across the 13 significant words.") %>%
html_table_width(c(150, 80))
| account | n |
|---|---|
| Language shift | 1 |
| Vocabulary gap | 10 |
Result: the data mostly contradicts the language-shift
hypothesis. Only 1 of the 11 matched words (“tạm biệt” /bye,
79.0%) shows the pattern the language-shift account predicts. The other
10 – including every animal word in the list (khỉ/monkey
33.9%, gấu/bear 33.3%, hươu/deer 18.8%,
gà trống/rooster 14.5%) and most of the rest
(hoa/flower 36.2%, dậy/wake 16.2%,
tiền/money 12.0%, của anh ấy/his 9.6%,
thịt/meat 9.2%, cách xa/away 8.3%) – show that
most bilingual non-producers of the Vietnamese word also don’t
produce its English equivalent. So for most of these words, bilingual
children in this sample aren’t quietly saying “bear” instead of “gấu” –
they mostly don’t productively say either. That favors a genuine
production gap specifically for these items (disproportionately animal
vocabulary) among bilingual children, over the language-shift account,
and reopens the original “representative vocabulary” framing rather than
explaining it away: whatever is driving the Vietnamese-only group’s
advantage on these specific words, it looks like it’s not just a
reporting artifact of which language a bilingual child happens to answer
in. (Two significant words, nệm/mattress and
cổ/neck, had no usable English match in
EngVieConceptMatch.xlsx and so aren’t part of this
check.)
A third, developmental view of the same question: for these 13 words, how does production change with age in each of three group x language combinations – Bilingual children’s Vietnamese production, Bilingual children’s English production (of the crosswalk-matched equivalent, where one exists), and Monolingual children’s Vietnamese production? If this is a genuine production gap rather than a language-of-expression shift, the Bilingual-English line should stay low across the whole observed age range, not just lag behind and catch up later.
sig_words_tbl <- vi_word_group_diff %>%
filter(p_fdr < 0.05, log_or > 0) %>%
select(definition, item_definition)
# Vietnamese production of each of the 13 words, by child, with that child's group and age.
vi_traj_long <- as_tibble(vi_data$d_mat[, sig_words_tbl$definition, drop = FALSE]) %>%
mutate(row_id = vi_demo$row_id, age = vi_demo$age, group = vi_demo$group) %>%
pivot_longer(cols = all_of(sig_words_tbl$definition), names_to = "definition", values_to = "produces") %>%
left_join(sig_words_tbl, by = "definition") %>%
mutate(language = "Vietnamese")
# English production of each word's crosswalk-matched equivalent (where one exists),
# for Bilingual children only -- reuses sig_mono_crosswalk's candidate-column lookup
# from the language-shift check above (a child counts as producing the "equivalent"
# if they produce ANY of that word's candidate English items).
en_traj_long <- sig_mono_crosswalk %>%
filter(n_en_candidates > 0) %>%
select(definition, item_definition, en_candidates) %>%
pmap_dfr(function(definition, item_definition, en_candidates) {
sub <- en_data$d_mat[, en_candidates, drop = FALSE]
produces <- if (ncol(sub) == 1) as.integer(sub[, 1]) else as.integer(apply(sub == 1, 1, any))
tibble(definition = definition, item_definition = item_definition,
row_id = en_data$d_demo$row_id, age = en_data$d_demo$age, produces = produces)
}) %>%
left_join(vi_demo %>% select(row_id, group), by = "row_id") %>%
filter(group == "Bilingual") %>%
mutate(language = "English")
traj_combined <- bind_rows(vi_traj_long, en_traj_long) %>%
mutate(line = paste0(group, " – ", language))
cat("Words with no English trajectory line (no crosswalk match):",
paste(setdiff(sig_words_tbl$item_definition, unique(en_traj_long$item_definition)), collapse = ", "), "\n")
## Words with no English trajectory line (no crosswalk match): cổ, nệm
age_bin_width <- 3 # wider than 05_item_difficulty_crosslang.Rmd's category trajectories (2 months),
# since the Monolingual group here is only 41 children spread across 13 words
traj_combined %>%
mutate(age_bin = floor(age / age_bin_width) * age_bin_width) %>%
group_by(group, language, line, age_bin) %>%
summarise(prop_producing = mean(produces), n_obs = n(), .groups = "drop") %>%
ggplot(aes(x = age_bin, y = prop_producing, color = group, linetype = language)) +
geom_line(linewidth = 0.8) +
geom_point(size = 1.8) +
theme_classic() +
scale_color_manual(values = c(Bilingual = "#1F78B4", Monolingual = "#E31A1C")) +
xlab("Age (months, binned)") + ylab("Mean proportion producing (across 13 words)") +
labs(color = NULL, linetype = NULL,
title = "Acquisition trajectory of the 13 Monolingual-favoring words")
traj_combined %>%
mutate(age_bin = floor(age / age_bin_width) * age_bin_width) %>%
group_by(item_definition, group, language, line, age_bin) %>%
summarise(prop_producing = mean(produces), n_obs = n(), .groups = "drop") %>%
ggplot(aes(x = age_bin, y = prop_producing, color = group, linetype = language)) +
geom_line(linewidth = 0.6) +
geom_point(size = 1) +
facet_wrap(~item_definition, ncol = 4) +
theme_classic() +
theme(legend.position = "bottom") +
scale_color_manual(values = c(Bilingual = "#1F78B4", Monolingual = "#E31A1C")) +
xlab("Age (months, binned)") + ylab("Proportion producing") +
labs(color = NULL, linetype = NULL)
Reading note: per-word bins can be thin (as few as 1-3 Monolingual children in an age bin), so individual-word curves are noisy by construction – the aggregate plot above is the more reliable summary; per-word panels are for spotting words that depart from the aggregate pattern (e.g. a word already near-ceiling in both groups by the youngest ages sampled, vs. one where the gap only opens up later).
The leading account for why these particular 13 words differ (worked out above: they’re moderate-difficulty, already-common-for-Monolinguals words, with animal vocabulary over-represented ~4.6x relative to its base rate) is input-dose dilution: Bilingual children split their waking hours across two languages, so for any given word they receive roughly half the cumulative Vietnamese exposure a Vietnamese-dominant peer gets – which mostly only matters for this moderate-difficulty band (the earliest/highest-frequency words reach ceiling under either amount of exposure; the hardest words are below floor for both groups regardless). That account makes a specific, testable prediction: among Bilingual children, more reported Vietnamese exposure should predict producing more of these 13 words – i.e. the gap should be exposure-graded, not a flat Bilingual-vs-Monolingual step function.
# Mirrors get_pct_active_lang() in 03_bifactor_noun_verb.Rmd: lang_i_pct_active is
# the % of a child's *active* language use taken up by whichever language sits in
# slot i (language_1..4); summing across slots where that slot is "Vietnamese"
# gives each child's overall % active Vietnamese use.
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)
}
vi_exposure <- read_csv("data/VietnameseWS_Bui_data.csv", show_col_types = FALSE) %>%
select(response_id, 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_vietnamese = get_pct_active_lang(., "Vietnamese")) %>%
select(response_id, pct_active_vietnamese)
vi_demo <- vi_demo %>% left_join(vi_exposure, by = "response_id")
# Sanity check: Monolingual children should report ~100% active Vietnamese use,
# validating pct_active_vietnamese = 100 as the "full exposure" reference point
# the plot below compares Bilingual children against.
vi_demo %>% group_by(group) %>%
summarise(mean_pct_active_vi = mean(pct_active_vietnamese, na.rm = TRUE),
median_pct_active_vi = median(pct_active_vietnamese, na.rm = TRUE), .groups = "drop") %>%
kable(digits = 1, caption = "% active Vietnamese use, by group.") %>%
html_table_width(c(90, 110, 110))
| group | mean_pct_active_vi | median_pct_active_vi |
|---|---|---|
| Bilingual | 52.7 | 50 |
| Monolingual | 69.3 | 95 |
n_sig_words_produced <- as_tibble(vi_data$d_mat[, sig_words_tbl$definition, drop = FALSE]) %>%
mutate(row_id = vi_demo$row_id) %>%
pivot_longer(-row_id, names_to = "definition", values_to = "produces") %>%
group_by(row_id) %>%
summarise(n_produced = sum(produces), prop_produced = mean(produces), .groups = "drop")
vi_demo_exposure <- vi_demo %>% left_join(n_sig_words_produced, by = "row_id")
monolingual_baseline <- vi_demo_exposure %>% filter(group == "Monolingual") %>% pull(prop_produced) %>% mean()
cat("Monolingual mean proportion of the 13 words produced (reference line below):",
round(monolingual_baseline, 3), "\n")
## Monolingual mean proportion of the 13 words produced (reference line below): 0.57
bilingual_exposure <- vi_demo_exposure %>% filter(group == "Bilingual")
summary(lm(prop_produced ~ pct_active_vietnamese + age_c, data = bilingual_exposure))
##
## Call:
## lm(formula = prop_produced ~ pct_active_vietnamese + age_c, data = bilingual_exposure)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.50196 -0.15545 -0.05066 0.16408 0.63192
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.0436849 0.0543616 0.804 0.424
## pct_active_vietnamese 0.0042526 0.0009245 4.600 1.26e-05 ***
## age_c 0.0209583 0.0040919 5.122 1.51e-06 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.244 on 98 degrees of freedom
## Multiple R-squared: 0.2923, Adjusted R-squared: 0.2779
## F-statistic: 20.24 on 2 and 98 DF, p-value: 4.38e-08
ggplot(bilingual_exposure, aes(x = pct_active_vietnamese, y = prop_produced)) +
geom_point(alpha = 0.5) +
geom_smooth(method = "lm", se = TRUE, color = "#1F78B4") +
geom_hline(yintercept = monolingual_baseline, linetype = "dashed", color = "#E31A1C") +
annotate("text", x = 5, y = monolingual_baseline + 0.03, label = "Monolingual mean",
color = "#E31A1C", hjust = 0, size = 3.5) +
theme_classic() +
xlab("% active Vietnamese use (Bilingual children)") +
ylab("Proportion of the 13 words produced") +
labs(title = "Does higher Vietnamese exposure close the gap?")
## `geom_smooth()` using formula = 'y ~ x'
If the input-dilution account is right, the regression line should
rise with pct_active_vietnamese and approach the
Monolingual reference line (dashed) at the high-exposure end; a flat
line would instead suggest the gap is tied to group membership itself
(or something else correlated with it) rather than graded by exposure
amount.
Exposure predicting production of these 13 words isn’t informative on
its own if exposure predicts everything equally well – overall
Vietnamese vocabulary size should obviously track Vietnamese exposure
too. The real question is whether these 13 words are more
exposure-sensitive than a typical Vietnamese word, as the input-dilution
+ “moderate-difficulty band” account would predict, or whether they just
ride along with a generic effect that’s no stronger here than anywhere
else in the vocabulary. Three outcomes, same model
(~ pct_active_vietnamese + age_c), same Bilingual children,
compared on a common scale (standardized exposure effect, i.e. the
correlation between pct_active_vietnamese and each outcome
controlling for age):
non_sig_words <- setdiff(colnames(vi_data$d_mat), sig_words_tbl$definition)
bilingual_exposure <- bilingual_exposure %>%
mutate(
prop_produced_13 = prop_produced, # the 13 significant words, already computed above
prop_produced_all = rowSums(vi_data$d_mat[vi_demo$group == "Bilingual", ]) / ncol(vi_data$d_mat),
prop_produced_nonsig13 = rowSums(vi_data$d_mat[vi_demo$group == "Bilingual", non_sig_words]) / length(non_sig_words)
)
fit_and_summarize <- function(outcome_var, label) {
f <- as.formula(paste0(outcome_var, " ~ pct_active_vietnamese + age_c"))
fit <- lm(f, data = bilingual_exposure)
cf <- summary(fit)$coefficients
# partial correlation of pct_active_vietnamese with the outcome, controlling for age_c
partial_r <- sign(cf["pct_active_vietnamese", "Estimate"]) *
sqrt(cf["pct_active_vietnamese", "t value"]^2 / (cf["pct_active_vietnamese", "t value"]^2 + fit$df.residual))
tibble(outcome = label, n_items = NA_integer_,
beta_exposure = cf["pct_active_vietnamese", "Estimate"],
p_exposure = cf["pct_active_vietnamese", "Pr(>|t|)"],
partial_r_exposure = partial_r,
r_squared = summary(fit)$r.squared)
}
comparison <- bind_rows(
fit_and_summarize("prop_produced_13", "The 13 significant words") %>% mutate(n_items = 13),
fit_and_summarize("prop_produced_nonsig13", "All other Vietnamese words") %>% mutate(n_items = length(non_sig_words)),
fit_and_summarize("prop_produced_all", "All 687 Vietnamese words (incl. the 13)") %>% mutate(n_items = ncol(vi_data$d_mat))
)
comparison %>%
select(outcome, n_items, beta_exposure, p_exposure, partial_r_exposure, r_squared) %>%
kable(digits = c(0, 0, 4, 4, 2, 2), caption = "Vietnamese-exposure effect on production, by word set (Bilingual children, controlling for age).") %>%
html_table_width(c(220, 70, 100, 90, 110, 80))
| outcome | n_items | beta_exposure | p_exposure | partial_r_exposure | r_squared |
|---|---|---|---|---|---|
| The 13 significant words | 13 | 0.0043 | 0e+00 | 0.42 | 0.29 |
| All other Vietnamese words | 674 | 0.0029 | 4e-04 | 0.35 | 0.27 |
| All 687 Vietnamese words (incl. the 13) | 687 | 0.0030 | 4e-04 | 0.35 | 0.27 |
bilingual_exposure %>%
select(pct_active_vietnamese, prop_produced_13, prop_produced_nonsig13, prop_produced_all) %>%
pivot_longer(-pct_active_vietnamese, names_to = "word_set", values_to = "prop_produced") %>%
mutate(word_set = recode(word_set,
prop_produced_13 = "The 13 significant words",
prop_produced_nonsig13 = "All other Vietnamese words",
prop_produced_all = "All 687 words")) %>%
ggplot(aes(x = pct_active_vietnamese, y = prop_produced, color = word_set)) +
geom_smooth(method = "lm", se = TRUE) +
theme_classic() +
scale_color_manual(values = c("The 13 significant words" = "#E31A1C",
"All other Vietnamese words" = "#1F78B4",
"All 687 words" = "grey50")) +
xlab("% active Vietnamese use (Bilingual children)") +
ylab("Proportion of word set produced") +
labs(color = NULL, title = "Exposure effect: the 13 words vs. the rest of the vocabulary")
## `geom_smooth()` using formula = 'y ~ x'
Lines are plotted on each outcome’s own proportion scale, so compare
steepness relative to each line’s own range (or just the
partial_r_exposure/r_squared columns above)
rather than raw vertical position – the 13-word outcome and the 674-word
outcome aren’t on directly comparable absolute scales (different
difficulty composition), which is exactly why the table reports a
standardized partial correlation instead of relying on the plot
alone.
The comparison above (13 words vs. the other 674) isn’t quite a fair test on its own: a 13-item average is noisier per child than a 674-item average purely from having fewer items to average over, and that extra noise should, if anything, attenuate a correlation with exposure (classic measurement-error dilution) – so it’s not obvious whether partial r = .42 is “high for a 13-word subset” or just typical of what any 13-word subset would show once you account for that smaller-sample noise. The direct fix: build a null distribution from many random same-sized (13-word) subsets of the Vietnamese vocabulary, and see where the real 13 words actually fall in it.
set.seed(2025)
n_perm <- 5000
bilingual_idx <- which(vi_demo$group == "Bilingual")
bilingual_mat <- vi_data$d_mat[bilingual_idx, ]
all_word_cols <- colnames(vi_data$d_mat)
# Same partial-correlation statistic as fit_and_summarize() above, computed for an
# arbitrary set of word columns against the fixed bilingual_exposure covariates.
compute_partial_r <- function(word_cols) {
prop <- rowSums(bilingual_mat[, word_cols, drop = FALSE]) / length(word_cols)
fit <- lm(prop ~ pct_active_vietnamese + age_c, data = bilingual_exposure)
t <- summary(fit)$coefficients["pct_active_vietnamese", "t value"]
sign(t) * sqrt(t^2 / (t^2 + fit$df.residual))
}
observed_r <- compute_partial_r(sig_words_tbl$definition)
null_r <- map_dbl(seq_len(n_perm), function(i) {
compute_partial_r(sample(all_word_cols, length(sig_words_tbl$definition)))
})
p_perm <- mean(null_r >= observed_r)
cat("Observed partial r (the 13 significant words):", round(observed_r, 3), "\n")
## Observed partial r (the 13 significant words): 0.421
cat("Null distribution (", n_perm, "random 13-word subsets): mean =", round(mean(null_r), 3),
", 95th percentile =", round(quantile(null_r, .95), 3), "\n")
## Null distribution ( 5000 random 13-word subsets): mean = 0.327 , 95th percentile = 0.383
cat("One-sided permutation p-value:", round(p_perm, 4), "\n")
## One-sided permutation p-value: 0.0028
tibble(null_r = null_r) %>%
ggplot(aes(x = null_r)) +
geom_histogram(bins = 50, fill = "grey75", color = "white") +
geom_vline(xintercept = observed_r, color = "#E31A1C", linewidth = 1) +
annotate("text", x = observed_r, y = Inf, label = "The 13 words", color = "#E31A1C",
hjust = -0.1, vjust = 1.5, size = 3.8) +
theme_classic() +
xlab("Partial r (exposure effect), random 13-word subset") +
ylab("Count (of 5,000 random draws)") +
labs(title = "Null distribution: exposure-sensitivity of random 13-word subsets")
If p_perm is small (conventionally < .05), that’s
evidence these particular 13 words are more exposure-sensitive than a
typical same-sized slice of the Vietnamese vocabulary – not just an
artifact of averaging over fewer items. A large p_perm
would mean the apparent specialness in the earlier comparison was mostly
a small-subset-noise artifact, and the honest claim is the more modest
one from the “all 674 vs. all 687” comparison above: exposure matters
for Vietnamese vocabulary generally, without these 13 words being a
distinct category.
The permutation test above draws random 13-word subsets from the
entire vocabulary, uniformly. But IRT item information is
mechanically highest near an item’s own difficulty: a word that sits
right at the ability level where children are actively transitioning
from “doesn’t produce” to “produces” is, by construction, maximally
sensitive to any small individual difference among children
that nudges them one way or the other – including exposure – regardless
of the word’s content. Since these 13 words skew toward
easier-than-median difficulty (median b = 0.82 vs. 1.29 for
the full vocabulary), part of their apparent exposure-sensitivity could
be nothing more than “words in this difficulty range are sensitive to
everything,” not anything specific to these words. The fix: instead of a
free random draw, match each of the 13 real words to a pool of
same-difficulty other words, and redraw from those pools
only.
load("models/vi/mod_2pl.Rds") # multi-object save() file -- see load() note in 05_item_difficulty_crosslang.Rmd
diff_vi_full <- as_tibble(coef(mod_2pl, simplify = TRUE, IRTpars = TRUE)$items) %>%
mutate(definition = rownames(coef(mod_2pl, simplify = TRUE, IRTpars = TRUE)$items)) %>%
select(definition, b)
rm(mod_2pl)
sig_difficulty <- diff_vi_full %>% filter(definition %in% sig_words_tbl$definition)
sig_difficulty %>%
arrange(b) %>%
left_join(sig_words_tbl, by = "definition") %>%
select(item_definition, b) %>%
kable(digits = 2, caption = "IRT difficulty (b) of the 13 significant words -- a wide spread, not a narrow band.") %>%
html_table_width(c(120, 80))
| item_definition | b |
|---|---|
| hoa | 0.08 |
| gấu | 0.22 |
| tạm biệt | 0.23 |
| khỉ | 0.33 |
| cổ | 0.36 |
| thịt | 0.43 |
| dậy | 0.82 |
| tiền | 0.87 |
| gà trống | 1.26 |
| nệm | 1.28 |
| hươu | 1.34 |
| của anh ấy | 2.10 |
| cách xa | 2.41 |
# For each of the 13 words, its 30 nearest neighbors by |difference in b|, excluding
# the 13 significant words themselves -- a k-nearest-neighbor pool rather than a fixed
# +-tolerance window, so it stays well-populated even for the hardest/easiest of the 13
# (checked: 28-166 words fall within +-0.15 to +-0.30 logits across this range, so k=30
# neighbors corresponds to a reasonably tight difficulty match throughout).
k_neighbors <- 30
match_pools <- map(sig_difficulty$b, function(b_target) {
diff_vi_full %>%
filter(!(definition %in% sig_words_tbl$definition)) %>%
mutate(dist = abs(b - b_target)) %>%
slice_min(dist, n = k_neighbors) %>%
pull(definition)
})
cat("Pool size per word (all should be", k_neighbors, "):", paste(lengths(match_pools), collapse = ", "), "\n")
## Pool size per word (all should be 30 ): 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30
set.seed(2025)
draw_matched_set <- function(pools) {
drawn <- character(0)
for (pool in pools) {
available <- setdiff(pool, drawn)
if (length(available) == 0) available <- pool # fallback; not expected to trigger with k=30
drawn <- c(drawn, sample(available, 1))
}
drawn
}
null_r_matched <- map_dbl(seq_len(n_perm), function(i) {
compute_partial_r(draw_matched_set(match_pools))
})
p_perm_matched <- mean(null_r_matched >= observed_r)
cat("Observed partial r:", round(observed_r, 3), "\n")
## Observed partial r: 0.421
cat("Difficulty-matched null (", n_perm, "draws): mean =", round(mean(null_r_matched), 3),
", 95th percentile =", round(quantile(null_r_matched, .95), 3), "\n")
## Difficulty-matched null ( 5000 draws): mean = 0.331 , 95th percentile = 0.378
cat("One-sided permutation p-value (difficulty-matched):", round(p_perm_matched, 4), "\n")
## One-sided permutation p-value (difficulty-matched): 6e-04
bind_rows(
tibble(r = null_r, null_type = "Unmatched (any 13 words)"),
tibble(r = null_r_matched, null_type = "Difficulty-matched")
) %>%
ggplot(aes(x = r, fill = null_type)) +
geom_histogram(bins = 50, alpha = 0.6, position = "identity") +
geom_vline(xintercept = observed_r, color = "#E31A1C", linewidth = 1) +
annotate("text", x = observed_r, y = Inf, label = "The 13 words", color = "#E31A1C",
hjust = -0.1, vjust = 1.5, size = 3.8) +
theme_classic() +
scale_fill_manual(values = c("Unmatched (any 13 words)" = "grey60", "Difficulty-matched" = "#1F78B4")) +
xlab("Partial r (exposure effect), random 13-word subset") +
ylab("Count (of 5,000 random draws)") +
labs(fill = NULL, title = "Does difficulty-matching absorb the effect?")
This directly answers both of the opening questions. If the
difficulty-matched null shifts right (closer to the unmatched null’s
right tail, or to observed_r itself) and
p_perm_matched is no longer small, then yes, this
is just a difficulty effect – any 687-item-vocabulary word near
this difficulty range would show the same exposure- sensitivity, and
there’s nothing else special about these 13. If
p_perm_matched stays small even after matching, that rules
out difficulty as the (sole) explanation: these words are more
exposure-sensitive than other words at the same difficulty,
which points back to something about their content (the animal-word
enrichment noted earlier being the most concrete candidate) rather than
a generic psychometric artifact of where they sit on the difficulty
scale.
Everything above started from the 13 words that happened to come out of the Monolingual-vs-Bilingual comparison. This section instead runs an unbiased sweep: for every word in both languages, among Bilingual children only, does production depend on how much of that specific language they’re reportedly exposed to? This both (a) checks whether the 13 words from before actually show up among the most exposure-sensitive words in an analysis that didn’t presuppose them, and (b) surfaces an English-side exposure-sensitive word list, which nothing above has looked at yet (everything so far was Vietnamese-only).
# pct_active_english, built the same way as pct_active_vietnamese earlier
# (get_pct_active_lang(), defined in the "Does Vietnamese exposure explain the gap?"
# section above) -- language_1..4 / lang_i_pct_active are demographic fields
# duplicated identically across the English and Vietnamese raw data files (verified
# directly: identical for every shared row_id), so either file would give the same answer.
pct_active_english_tbl <- read_csv("data/EnglishAmericanWS_Bui_data_redact.csv", show_col_types = FALSE) %>%
select(response_id, 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")) %>%
select(response_id, pct_active_english)
vi_demo <- vi_demo %>% left_join(pct_active_english_tbl, by = "response_id")
bilingual_full <- vi_demo %>% filter(group == "Bilingual")
# en_data$d_mat and vi_data$d_mat share row order with vi_demo (same invariant used
# throughout this project: both raw CSVs were confirmed row-for-row aligned by
# response_id earlier in this analysis), so the same logical index subsets all three.
is_bilingual <- vi_demo$group == "Bilingual"
vi_bilingual_mat <- vi_data$d_mat[is_bilingual, ]
en_bilingual_mat <- en_data$d_mat[is_bilingual, ]
# Per-item logistic regression of production on that language's exposure measure,
# controlling for age -- the whole-vocabulary analogue of the single-item checks
# above. Items with zero variance, non-convergent fits, or quasi-separation
# (SE > 5, same guard used for compute_item_aoa()/DIF elsewhere in this project) are
# returned as NA rather than trusted.
compute_exposure_effect <- function(d_mat, d_items, exposure, age_c) {
n_items <- ncol(d_mat)
defs <- colnames(d_mat)
map_dfr(seq_len(n_items), function(j) {
y <- d_mat[, j]
if (length(unique(y)) < 2) return(tibble(definition = defs[j], beta_exposure = NA_real_, p_exposure = NA_real_))
fit <- tryCatch(glm(y ~ exposure + age_c, family = binomial), error = function(e) NULL)
if (is.null(fit) || !fit$converged) return(tibble(definition = defs[j], beta_exposure = NA_real_, p_exposure = NA_real_))
cf <- summary(fit)$coefficients
if (!("exposure" %in% rownames(cf))) return(tibble(definition = defs[j], beta_exposure = NA_real_, p_exposure = NA_real_))
est <- cf["exposure", "Estimate"]; se <- cf["exposure", "Std. Error"]
if (!is.finite(se) || se > 5) return(tibble(definition = defs[j], beta_exposure = NA_real_, p_exposure = NA_real_))
tibble(definition = defs[j], beta_exposure = est, p_exposure = cf["exposure", "Pr(>|z|)"])
}) %>%
mutate(p_fdr = p.adjust(p_exposure, method = "BH")) %>%
left_join(d_items, by = "definition")
}
vi_exposure_effects <- compute_exposure_effect(vi_bilingual_mat, vi_data$d_items,
bilingual_full$pct_active_vietnamese, bilingual_full$age_c)
en_exposure_effects <- compute_exposure_effect(en_bilingual_mat, en_data$d_items,
bilingual_full$pct_active_english, bilingual_full$age_c)
cat("Vietnamese: ", sum(vi_exposure_effects$p_fdr < 0.05, na.rm = TRUE), "/", nrow(vi_exposure_effects),
" words FDR-significant for exposure effect\n", sep = "")
## Vietnamese: 163/687 words FDR-significant for exposure effect
cat("English: ", sum(en_exposure_effects$p_fdr < 0.05, na.rm = TRUE), "/", nrow(en_exposure_effects),
" words FDR-significant for exposure effect\n", sep = "")
## English: 345/681 words FDR-significant for exposure effect
vi_exposure_effects %>%
filter(!is.na(p_fdr)) %>%
arrange(p_fdr) %>%
slice_head(n = 20) %>%
select(item_definition, item_kind, beta_exposure, p_exposure, p_fdr) %>%
kable(digits = c(0, 0, 4, 4, 4), caption = "Vietnamese words most sensitive to % active Vietnamese exposure (Bilingual children).") %>%
html_table_width(c(140, 90, 90, 90, 90))
| item_definition | item_kind | beta_exposure | p_exposure | p_fdr |
|---|---|---|---|---|
| kính | household | 0.0451 | 0.0000 | 0.0332 |
| hổ | animals | 0.0491 | 0.0001 | 0.0344 |
| chơi | action_words | 0.0274 | 0.0020 | 0.0350 |
| cắn | action_words | 0.0292 | 0.0018 | 0.0350 |
| giấu | action_words | 0.0361 | 0.0021 | 0.0350 |
| nhặt | action_words | 0.0361 | 0.0014 | 0.0350 |
| chuột | animals | 0.0356 | 0.0004 | 0.0350 |
| cú | animals | 0.0369 | 0.0021 | 0.0350 |
| cừu | animals | 0.0460 | 0.0002 | 0.0350 |
| gấu | animals | 0.0355 | 0.0004 | 0.0350 |
| gấu trúc | animals | 0.0384 | 0.0011 | 0.0350 |
| hươu | animals | 0.0454 | 0.0012 | 0.0350 |
| hươu cao cổ | animals | 0.0383 | 0.0005 | 0.0350 |
| kiến | animals | 0.0350 | 0.0010 | 0.0350 |
| rùa | animals | 0.0319 | 0.0021 | 0.0350 |
| sóc | animals | 0.0374 | 0.0010 | 0.0350 |
| sư tử | animals | 0.0346 | 0.0007 | 0.0350 |
| rốn | body_parts | 0.0392 | 0.0004 | 0.0350 |
| cứng | descriptive_words | 0.0421 | 0.0015 | 0.0350 |
| nặng | descriptive_words | 0.0391 | 0.0014 | 0.0350 |
en_exposure_effects %>%
filter(!is.na(p_fdr)) %>%
arrange(p_fdr) %>%
slice_head(n = 20) %>%
select(item_definition, item_kind, beta_exposure, p_exposure, p_fdr) %>%
kable(digits = c(0, 0, 4, 4, 4), caption = "English words most sensitive to % active English exposure (Bilingual children).") %>%
html_table_width(c(140, 90, 90, 90, 90))
| item_definition | item_kind | beta_exposure | p_exposure | p_fdr |
|---|---|---|---|---|
| swing | action_words | 0.0446 | 4e-04 | 0.0166 |
| owl | animals | 0.0335 | 6e-04 | 0.0166 |
| cheek | body_parts | 0.0382 | 2e-04 | 0.0166 |
| chin | body_parts | 0.0327 | 7e-04 | 0.0166 |
| leg | body_parts | 0.0361 | 6e-04 | 0.0166 |
| tongue | body_parts | 0.0467 | 3e-04 | 0.0166 |
| tooth | body_parts | 0.0332 | 7e-04 | 0.0166 |
| pants | clothing | 0.0342 | 6e-04 | 0.0166 |
| shirt | clothing | 0.0357 | 4e-04 | 0.0166 |
| shoe | clothing | 0.0399 | 1e-04 | 0.0166 |
| sock | clothing | 0.0316 | 4e-04 | 0.0166 |
| bad | descriptive_words | 0.0472 | 6e-04 | 0.0166 |
| happy | descriptive_words | 0.0355 | 3e-04 | 0.0166 |
| milk | food_drink | 0.0366 | 2e-04 | 0.0166 |
| gonna get you! | games_routines | 0.0472 | 4e-04 | 0.0166 |
| please | games_routines | 0.0346 | 3e-04 | 0.0166 |
| basket | household | 0.0487 | 6e-04 | 0.0166 |
| bottle | household | 0.0342 | 6e-04 | 0.0166 |
| bowl | household | 0.0396 | 3e-04 | 0.0166 |
| pillow | household | 0.0372 | 5e-04 | 0.0166 |
vi_exposure_effects %>%
filter(definition %in% sig_words_tbl$definition) %>%
mutate(rank_out_of = nrow(vi_exposure_effects)) %>%
left_join(vi_exposure_effects %>% arrange(p_fdr) %>% mutate(rank = row_number()) %>% select(definition, rank),
by = "definition") %>%
arrange(rank) %>%
select(item_definition, item_kind, beta_exposure, p_fdr, rank, rank_out_of) %>%
kable(digits = c(0, 0, 4, 4, 0, 0),
caption = "Where the original 13 words rank in this unbiased, whole-vocabulary exposure sweep.") %>%
html_table_width(c(140, 90, 90, 90, 70, 90))
| item_definition | item_kind | beta_exposure | p_fdr | rank | rank_out_of |
|---|---|---|---|---|---|
| gấu | animals | 0.0355 | 0.0350 | 10 | 687 |
| hươu | animals | 0.0454 | 0.0350 | 12 | 687 |
| tạm biệt | games_routines | 0.0343 | 0.0350 | 32 | 687 |
| nệm | household | 0.0440 | 0.0352 | 49 | 687 |
| gà trống | animals | 0.0343 | 0.0388 | 67 | 687 |
| khỉ | animals | 0.0237 | 0.0495 | 143 | 687 |
| hoa | outdoor | 0.0215 | 0.0495 | 152 | 687 |
| dậy | action_words | 0.0247 | 0.0496 | 156 | 687 |
| cổ | body_parts | 0.0213 | 0.0543 | 184 | 687 |
| thịt | food_drink | 0.0183 | 0.0842 | 288 | 687 |
| tiền | household | 0.0193 | 0.0912 | 314 | 687 |
| của anh ấy | pronouns | 0.0372 | 0.0923 | 320 | 687 |
| cách xa | locations | 0.0194 | 0.3341 | 606 | 687 |
bind_rows(
vi_exposure_effects %>% mutate(language = "Vietnamese"),
en_exposure_effects %>% mutate(language = "English")
) %>%
filter(!is.na(p_exposure)) %>%
ggplot(aes(x = beta_exposure, y = -log10(p_exposure), color = p_fdr < 0.05)) +
geom_point(alpha = 0.5, size = 1) +
geom_vline(xintercept = 0, linetype = "dashed", color = "grey50") +
facet_wrap(~language, scales = "free_x") +
theme_classic() +
scale_color_manual(values = c(`TRUE` = "#D55E00", `FALSE` = "grey70")) +
xlab("Exposure effect (logistic regression coefficient)") +
ylab(expression(-log[10]("p-value"))) +
labs(color = "FDR < .05", title = "Exposure-sensitivity across the whole vocabulary, by language")
A sharper, whole-vocabulary version of the difficulty question from
earlier: rather than asking whether the 13 words’ competition
effect correlates with difficulty (RQ4, tested on the EN-only/VI-only
subset), this asks whether each item’s raw exposure-sensitivity
(beta_exposure, from the per-item regressions just
computed, across all 687 Vietnamese and 681 English words)
depends on its IRT difficulty – and specifically whether that dependence
is an inverted U (peak sensitivity at moderate
difficulty, low at both ceiling and floor), the shape predicted by IRT
item information being maximized near an item’s own difficulty, rather
than a simple linear trend.
# English difficulty, loaded the same way as diff_vi_full above (05_item_difficulty_crosslang.Rmd's convention).
load("models/en/mod_2pl.Rds")
diff_en_full <- as_tibble(coef(mod_2pl, simplify = TRUE, IRTpars = TRUE)$items) %>%
mutate(definition = rownames(coef(mod_2pl, simplify = TRUE, IRTpars = TRUE)$items)) %>%
select(definition, b)
rm(mod_2pl)
vi_moderation <- vi_exposure_effects %>%
filter(!is.na(beta_exposure)) %>%
left_join(diff_vi_full %>% select(definition, b), by = "definition") %>%
mutate(language = "Vietnamese")
en_moderation <- en_exposure_effects %>%
filter(!is.na(beta_exposure)) %>%
left_join(diff_en_full %>% select(definition, b), by = "definition") %>%
mutate(language = "English")
fit_moderation <- function(d, label) {
lin <- lm(beta_exposure ~ b, data = d)
quad <- lm(beta_exposure ~ b + I(b^2), data = d)
comparison <- anova(lin, quad)
vertex <- -coef(quad)["b"] / (2 * coef(quad)["I(b^2)"])
cat("===", label, "===\n")
cat("Linear b coefficient:", round(coef(lin)["b"], 4), ", p =", round(summary(lin)$coefficients["b","Pr(>|t|)"], 4), "\n")
cat("Quadratic b^2 coefficient:", round(coef(quad)["I(b^2)"], 5),
", p =", round(summary(quad)$coefficients["I(b^2)","Pr(>|t|)"], 4), "\n")
cat("Does adding the quadratic term improve fit? p =", round(comparison$`Pr(>F)`[2], 4), "\n")
cat("Implied peak-sensitivity difficulty (vertex):", round(vertex, 2),
"(negative b^2 coefficient means this is a maximum, not a minimum)\n\n")
}
fit_moderation(vi_moderation, "Vietnamese")
## === Vietnamese ===
## Linear b coefficient: 0.0034 , p = 0
## Quadratic b^2 coefficient: -0.00095 , p = 0.002
## Does adding the quadratic term improve fit? p = 0.002
## Implied peak-sensitivity difficulty (vertex): 2.92 (negative b^2 coefficient means this is a maximum, not a minimum)
fit_moderation(en_moderation, "English")
## === English ===
## Linear b coefficient: 0.0042 , p = 0
## Quadratic b^2 coefficient: -0.00179 , p = 0.0024
## Does adding the quadratic term improve fit? p = 0.0024
## Implied peak-sensitivity difficulty (vertex): 3.06 (negative b^2 coefficient means this is a maximum, not a minimum)
bind_rows(vi_moderation, en_moderation) %>%
ggplot(aes(x = b, y = beta_exposure)) +
geom_point(alpha = 0.3, size = 1) +
geom_smooth(method = "lm", formula = y ~ x + I(x^2), se = TRUE, color = "#E31A1C") +
geom_hline(yintercept = 0, linetype = "dashed", color = "grey50") +
facet_wrap(~language, scales = "free") +
theme_classic() +
xlab("IRT difficulty (b)") +
ylab("Exposure-sensitivity (beta_exposure)") +
labs(title = "Does exposure-sensitivity peak at moderate difficulty?")
If the quadratic term is significant and negative (and the model-comparison p-value is small), that’s direct evidence difficulty moderates the exposure effect in the inverted-U shape IRT information predicts. If it isn’t, exposure-sensitivity doesn’t simply track “how mechanically sensitive this difficulty level is” – reinforcing, from a different angle than the earlier matched-permutation test, that content (not psychometric position alone) is doing real work.
Everything above treated each language’s vocabulary growth as if it were independent – more Vietnamese exposure predicts more Vietnamese words, more English exposure predicts more English words. A different, stronger claim is competition: for a single concept a child knows (e.g. “dog”), does which language they happen to produce it in depend on their relative exposure balance, as if the two languages’ words for the same concept compete for a single “production slot”? The clean test: restrict to children who produce a concept in exactly one of the two languages (not both, not neither), and check whether English-only producers of that concept skew toward English-dominant exposure while Vietnamese-only producers skew toward Vietnamese-dominant exposure.
# All usable EN-VI concept matches (not just the 13 words from earlier) -- same
# candidate-filtering logic as sig_mono_crosswalk above, applied to the full table.
concept_match_full <- concept_match %>%
mutate(en_candidates = map(en_candidates, ~ intersect(.x, en_data$d_items$definition))) %>%
filter(lengths(en_candidates) > 0) %>%
left_join(vi_data$d_items %>% select(definition, item_definition), by = c("vi_definition" = "definition"))
cat(nrow(concept_match_full), "concept pairs have a usable match for this analysis.\n")
## 639 concept pairs have a usable match for this analysis.
relative_exposure <- bilingual_full$pct_active_english - bilingual_full$pct_active_vietnamese # + = English-dominant
# For one concept: classifies each Bilingual child as Both/Neither/EN_only/VI_only,
# based on that concept's Vietnamese column and (possibly multiple) English candidate
# columns (produces the concept in English if they produce ANY candidate).
classify_concept <- function(vi_col, en_cols) {
vi_produces <- vi_bilingual_mat[, vi_col]
en_sub <- en_bilingual_mat[, en_cols, drop = FALSE]
en_produces <- if (ncol(en_sub) == 1) as.integer(en_sub[, 1]) else as.integer(apply(en_sub == 1, 1, any))
case_when(
vi_produces == 1 & en_produces == 1 ~ "Both",
vi_produces == 0 & en_produces == 0 ~ "Neither",
vi_produces == 0 & en_produces == 1 ~ "EN_only",
vi_produces == 1 & en_produces == 0 ~ "VI_only"
)
}
min_n_per_group <- 4 # below this, a two-group comparison isn't meaningfully powered
competition_results <- pmap_dfr(
list(concept_match_full$vi_definition, concept_match_full$en_candidates, concept_match_full$item_definition),
function(vi_col, en_cols, item_definition) {
category <- classify_concept(vi_col, en_cols)
n_en <- sum(category == "EN_only"); n_vi <- sum(category == "VI_only")
if (n_en < min_n_per_group || n_vi < min_n_per_group) {
return(tibble(vi_definition = vi_col, item_definition = item_definition,
n_en_only = n_en, n_vi_only = n_vi, effect = NA_real_, p_value = NA_real_))
}
exp_en <- relative_exposure[category == "EN_only"]
exp_vi <- relative_exposure[category == "VI_only"]
test <- wilcox.test(exp_en, exp_vi)
tibble(vi_definition = vi_col, item_definition = item_definition,
n_en_only = n_en, n_vi_only = n_vi,
effect = mean(exp_en) - mean(exp_vi), # + = EN-only producers are more English-dominant, as competition predicts
p_value = test$p.value)
}
) %>%
left_join(vi_data$d_items %>% select(definition, item_kind), by = c("vi_definition" = "definition")) %>%
mutate(p_fdr = p.adjust(p_value, method = "BH"))
cat(sum(!is.na(competition_results$p_value)), "/", nrow(competition_results),
"concepts had enough EN-only and VI-only producers (n >=", min_n_per_group, "each) to test.\n")
## 587 / 639 concepts had enough EN-only and VI-only producers (n >= 4 each) to test.
cat(sum(competition_results$p_fdr < 0.05, na.rm = TRUE), "concepts show a significant competition effect at FDR < .05.\n")
## 551 concepts show a significant competition effect at FDR < .05.
competition_results %>%
filter(!is.na(p_value)) %>%
arrange(p_fdr) %>%
slice_head(n = 20) %>%
select(item_definition, item_kind, n_en_only, n_vi_only, effect, p_value, p_fdr) %>%
kable(digits = c(0, 0, 0, 0, 1, 4, 4),
caption = "Strongest language-competition words: EN-only producers' exposure balance vs. VI-only producers' (positive effect = competition-consistent).") %>%
html_table_width(c(130, 90, 70, 70, 70, 80, 80))
| item_definition | item_kind | n_en_only | n_vi_only | effect | p_value | p_fdr |
|---|---|---|---|---|---|---|
| giấu | action_words | 16 | 16 | 84.7 | 0 | 0e+00 |
| yêu | action_words | 16 | 19 | 83.9 | 0 | 0e+00 |
| chuột | animals | 15 | 14 | 101.4 | 0 | 0e+00 |
| gấu | animals | 20 | 11 | 95.9 | 0 | 0e+00 |
| ngựa | animals | 21 | 18 | 81.7 | 0 | 0e+00 |
| nặng | descriptive_words | 13 | 14 | 107.7 | 0 | 0e+00 |
| trứng | food_drink | 25 | 13 | 89.4 | 0 | 0e+00 |
| bát | household | 19 | 18 | 83.0 | 0 | 0e+00 |
| gối | household | 17 | 20 | 86.4 | 0 | 0e+00 |
| kính | household | 13 | 20 | 100.1 | 0 | 0e+00 |
| rổ | household | 13 | 16 | 106.3 | 0 | 0e+00 |
| bò | animals | 16 | 15 | 94.4 | 0 | 0e+00 |
| rốn | body_parts | 22 | 16 | 80.6 | 0 | 0e+00 |
| búp bê | toys | 14 | 23 | 81.2 | 0 | 0e+00 |
| trèo | action_words | 8 | 19 | 109.7 | 0 | 0e+00 |
| ngoài | places | 17 | 12 | 89.4 | 0 | 0e+00 |
| thuyền | vehicles | 19 | 9 | 101.9 | 0 | 0e+00 |
| giày | clothing | 20 | 15 | 87.4 | 0 | 1e-04 |
| lưỡi | body_parts | 13 | 25 | 87.0 | 0 | 1e-04 |
| quần | clothing | 10 | 25 | 101.5 | 0 | 1e-04 |
top_competition <- competition_results %>% filter(!is.na(p_value)) %>% slice_min(p_fdr, n = 10)
bind_rows(
top_competition %>% transmute(item_definition, value = effect, type = "Observed effect"),
) %>%
mutate(item_definition = fct_reorder(item_definition, value)) %>%
ggplot(aes(x = value, y = item_definition)) +
geom_col(fill = "#6A3D9A") +
geom_vline(xintercept = 0, linetype = "dashed", color = "grey40") +
theme_classic() +
xlab("Mean relative exposure, EN-only producers minus VI-only producers\n(+ = consistent with competition)") +
ylab(NULL) +
labs(title = "Top 10 language-competition words")
The honest summary looks different from a short “top competing words”
list. Of 587 testable concepts, 551 (94%) show a
significant competition effect, and – more strikingly – not a
single one of the 587 goes in the wrong direction (every single
effect estimate is positive: EN-only producers are always
more English-dominant on average than VI-only producers for that same
concept, never the reverse). Effect sizes are large throughout (median
76 percentage points of relative exposure, on a scale that tops out at
200). This is a far more pervasive, far stronger pattern than “a few
words show competition” – it looks like which language a bilingual child
happens to produce a given concept in is overwhelmingly determined by
their overall language dominance, for nearly any concept they know in
only one language.
competition_results %>%
filter(!is.na(p_value)) %>%
arrange(desc(p_fdr)) %>%
slice_head(n = 15) %>%
select(item_definition, item_kind, n_en_only, n_vi_only, effect, p_fdr) %>%
kable(digits = c(0, 0, 0, 0, 1, 3),
caption = "The 15 WEAKEST competition words -- still positive-direction, just smaller/less reliable than the rest.") %>%
html_table_width(c(130, 110, 70, 70, 70, 80)
)
| item_definition | item_kind | n_en_only | n_vi_only | effect | p_fdr |
|---|---|---|---|---|---|
| meo | sounds | 6 | 10 | 13.0 | 0.800 |
| không | games_routines | 24 | 12 | 9.5 | 0.697 |
| be be | sounds | 35 | 5 | 18.3 | 0.452 |
| máy kéo | vehicles | 15 | 5 | 35.3 | 0.216 |
| chào | games_routines | 31 | 15 | 23.5 | 0.169 |
| cô ấy | pronouns | 11 | 7 | 39.0 | 0.146 |
| em bé | people | 14 | 13 | 33.1 | 0.136 |
| của | locations | 4 | 24 | 53.8 | 0.131 |
| ngựa con | animals | 10 | 13 | 39.7 | 0.127 |
| hơn | quantifiers | 26 | 13 | 32.3 | 0.118 |
| nên | connecting_words | 4 | 5 | 77.5 | 0.113 |
| của chúng ta | pronouns | 7 | 4 | 45.4 | 0.111 |
| cúc cù cu cu | sounds | 11 | 7 | 50.0 | 0.107 |
| cây | household | 5 | 25 | 48.9 | 0.107 |
| và | connecting_words | 11 | 11 | 38.2 | 0.104 |
Given that, the more interesting question flips: not “which words
show competition” (nearly all do) but “which words show it
least.” The weakest cases above are disproportionately
high-frequency routine words (không/no,
chào/hello, sound effects like meo/meow,
be be/baa) – exactly the category identified earlier as
least exposure-sensitive in general (robust to dilution because they
come up constantly regardless of total exposure). That’s a coherent
story: routine words are frequent enough in either language’s
input that even a non-dominant- language child picks them up and
produces them somewhat independent of overall dominance, slightly
blurring the EN-only/VI-only split for exactly these items – while
content words with no such frequency floor sort almost perfectly by
language dominance.
pooled_data <- pmap_dfr(
list(concept_match_full$vi_definition, concept_match_full$en_candidates),
function(vi_col, en_cols) {
category <- classify_concept(vi_col, en_cols)
keep <- category %in% c("EN_only", "VI_only")
if (sum(keep) == 0) return(NULL)
tibble(is_en_only = as.integer(category[keep] == "EN_only"), relative_exposure = relative_exposure[keep])
}
)
cat("Pooled rows (every child x concept where they produced exactly one language's word):", nrow(pooled_data), "\n")
## Pooled rows (every child x concept where they produced exactly one language's word): 16953
summary(glm(is_en_only ~ relative_exposure, family = binomial, data = pooled_data))
##
## Call:
## glm(formula = is_en_only ~ relative_exposure, family = binomial,
## data = pooled_data)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -0.0436024 0.0194677 -2.24 0.0251 *
## relative_exposure 0.0292787 0.0004358 67.18 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 23451 on 16952 degrees of freedom
## Residual deviance: 16304 on 16951 degrees of freedom
## AIC: 16308
##
## Number of Fisher Scoring iterations: 4
Caveat this pooled model does not fix: each Bilingual child contributes one row per concept they’re a single- language producer for, so the same ~101 children appear many times each – these rows are not independent observations, which makes the pooled model’s standard errors (and its p-value) too optimistic. It’s included only as a large-sample sanity check that the direction and rough size of the effect matches the per-concept results above, not as a properly-powered significance test on its own; the per-concept Wilcoxon tests (one independent set of children per concept, though still reusing many of the same ~101 children across different concepts) are the more defensible unit of evidence, and even those should be read as suggestive given how many concepts are being screened at a fairly small per-concept n.
The weakest competition words identified above (không/no, chào/hello,
meo/meow, be be/baa) were only inspected qualitatively.
H7 predicts this formally: high-frequency, functionally
routine words (produced through obligatory daily-routine input that both
languages supply regardless of overall dominance) should show a
smaller competition effect than discretionary content words,
whose production depends more on which language gets more dedicated
engagement time. Two operationalizations of “frequency-robust,” tested
against competition_results’ effect column
(already confirmed earlier: positive for every one of the 587 testable
concepts):
b, from
the Vietnamese 2PL model, already loaded as diff_vi_full
above) – easier/more frequent words should show a smaller
competition effect, so H7 predicts a positive
correlation between b and effect (harder/rarer
words show bigger dominance-driven separation).games_routines, sounds,
connecting_words, helping_verbs,
quantifiers, pronouns, time_words
– chosen a priori as the routine/functional word classes, not post-hoc)
vs. open-class content categories (animals, food/drink, clothing,
vehicles, toys, outdoor, household, furniture/rooms, body parts, people,
places, descriptive words, action words, locations). H7 predicts
functional categories show smaller effect than content
categories.competition_with_difficulty <- competition_results %>%
filter(!is.na(effect)) %>%
left_join(diff_vi_full %>% select(definition, b), by = c("vi_definition" = "definition"))
routine_categories <- c("games_routines", "sounds", "connecting_words", "helping_verbs", "quantifiers",
"pronouns", "time_words")
competition_with_difficulty <- competition_with_difficulty %>%
mutate(word_class = if_else(item_kind %in% routine_categories, "Functional/routine", "Content"))
table(competition_with_difficulty$word_class)
##
## Content Functional/routine
## 497 90
cor.test(competition_with_difficulty$b, competition_with_difficulty$effect, method = "pearson")
##
## Pearson's product-moment correlation
##
## data: competition_with_difficulty$b and competition_with_difficulty$effect
## t = 5.4855, df = 585, p-value = 6.143e-08
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## 0.1428115 0.2967958
## sample estimates:
## cor
## 0.2211818
ggplot(competition_with_difficulty, aes(x = b, y = effect)) +
geom_point(alpha = 0.4, size = 1) +
geom_smooth(method = "lm", se = TRUE, color = "#E31A1C") +
theme_classic() +
xlab("IRT difficulty (b) -- higher = harder/rarer") +
ylab("Competition effect (relative-exposure separation)") +
labs(title = "Does a harder/rarer word show a bigger competition effect?")
## `geom_smooth()` using formula = 'y ~ x'
competition_with_difficulty %>%
group_by(word_class) %>%
summarise(n = n(), mean_effect = mean(effect), median_effect = median(effect), .groups = "drop") %>%
kable(digits = 1, caption = "Competition effect size by word class.") %>%
html_table_width(c(150, 60, 100, 100))
| word_class | n | mean_effect | median_effect |
|---|---|---|---|
| Content | 497 | 77.3 | 77.1 |
| Functional/routine | 90 | 69.3 | 70.2 |
wilcox.test(effect ~ word_class, data = competition_with_difficulty)
##
## Wilcoxon rank sum test with continuity correction
##
## data: effect by word_class
## W = 26758, p-value = 0.003011
## alternative hypothesis: true location shift is not equal to 0
ggplot(competition_with_difficulty, aes(x = word_class, y = effect, fill = word_class)) +
geom_boxplot(outlier.shape = NA) +
geom_jitter(width = 0.15, alpha = 0.3) +
theme_classic() +
theme(legend.position = "none") +
scale_fill_manual(values = c(`Functional/routine` = "#1F78B4", Content = "#E31A1C")) +
xlab(NULL) + ylab("Competition effect (relative-exposure separation)") +
labs(title = "Functional/routine vs. content words")
If both tests come back in the predicted direction (positive difficulty correlation; functional words showing a smaller effect than content words), H7 is supported as a formal, quantitative claim rather than an impression from eyeballing the weakest cases – and the paper can report the correlation coefficient and group means directly rather than naming four illustrative words.
glm_or_age_adj column is included per word as
a check regardless – a word whose sign flips or becomes NA
there is less trustworthy as a genuine group effect.