Are item difficulties consistent in English and Vietnamese? Is this mediated by item-specific characteristics, e.g., noun/verb, concreteness, socialness, phonetics etc. How consistent are item difficulty ranking and age of acquisition norms? AoA norms are difficult to estimate for bilingual children, because they interact with age of acquisition onset (Schulz & Grimm 2019). So it’s possible that item difficulty ranking is a better measure for trajectory. Are there demographic factors that determine item difficulty (see Kachergis paper) Possible big question: How much of bilingual word learning trajectory is determined by difficulty of learning underlying concepts vs. language-specific inputs?
Three questions about item difficulty, in increasing order of how much cross-language machinery they need:
item_kind) tend to be produced earliest/most easily? Does
the same category-level pattern show up in both English and Vietnamese?
This only needs item_kind, which is already shared across
the two fields files – no item-level translation matching required.noun_kinds <- c("animals", "body_parts", "clothing", "food_drink", "toys", "vehicles",
"household", "furniture_rooms", "outdoor")
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_"
),
vi = list(
data_file = "data/VietnameseWS_Bui_data.csv",
fields_file = "data/VietnameseWS_Bui_fields.csv",
prefix = "cat_vie_"
)
)
# Loads the item-response matrix (d_mat), demographics (d_demo), item
# metadata (d_items), and the original wide response data (d_wide) for one
# language. d_mat/d_demo/d_wide all share row order (all three are derived
# from the same read_csv() call on the same file), which the rest of this
# Rmd relies on for position-based subsetting -- there is no join key back to
# d_mat's rows other than row position.
#
# d_demo additionally derives n_languages_household (count of non-missing
# language_1..language_4 slots, i.e. how many languages the child is
# reported to be exposed to) and a <=2-vs->2-languages grouping from it,
# used in Part 2's demographic-bias analysis. A monolingual/multilingual (1
# vs. 2+) cut was considered first but rejected: only 11/142 children are
# truly monolingual, too few to fit a per-group model reliably. The
# distribution is 11/89/34/8 for 1/2/3/4 languages, so <=2 vs. >2 gives a
# much more workable 100/42 split.
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, ppl_1_edu,
language_1, language_2, language_3, language_4) %>%
mutate(
n_languages_household = rowSums(!is.na(across(c(language_1, language_2, language_3, language_4)))),
n_languages_group = factor(if_else(n_languages_household > 2, "more_than_2", "2_or_fewer"),
levels = c("2_or_fewer", "more_than_2"))
)
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, "^.*?___"),
domain = case_when(
item_kind %in% verb_kinds ~ "verb",
item_kind %in% noun_kinds ~ "noun",
TRUE ~ "other"
)
) %>%
rename(item_id = field) %>%
select(-c(group, type))
d_mat <- d_wide %>% data.frame() %>% select(-response_id) %>% data.matrix()
stopifnot(all(colnames(d_mat) == d_items$definition))
list(d_mat = d_mat, d_demo = d_demo, d_items = d_items, d_wide = d_wide)
}
en_data <- load_lang_data("en")
vi_data <- load_lang_data("vi")
Two complementary views of “what gets learned first, and does it match across languages”:
models/en/mod_2pl.Rds, models/vi/mod_2pl.Rds,
from
02_IRT_analysis_en.Rmd/02_IRT_analysis_vi.Rmd),
extract each item’s difficulty (b) and look at how it’s
distributed within and across item_kind categories. Lower
b = easier / produced earlier.get_item_difficulty <- function(mod, d_items) {
as_tibble(coef(mod, simplify = TRUE, IRTpars = TRUE)$items) %>%
mutate(definition = rownames(coef(mod, simplify = TRUE)$items)) %>%
left_join(d_items, by = "definition") %>%
select(definition, item_kind, domain, item_definition, a, b) %>%
arrange(b) %>%
mutate(difficulty_rank = row_number()) # 1 = easiest
}
load("models/en/mod_2pl.Rds"); mod_2pl_en <- mod_2pl; rm(mod_2pl)
load("models/vi/mod_2pl.Rds"); mod_2pl_vi <- mod_2pl; rm(mod_2pl)
diff_en <- get_item_difficulty(mod_2pl_en, en_data$d_items) %>% mutate(language = "English")
diff_vi <- get_item_difficulty(mod_2pl_vi, vi_data$d_items) %>% mutate(language = "Vietnamese")
# Category-level (item_kind) average difficulty, ranked -- "which kinds of
# words tend to be produced earliest" within each language.
category_difficulty <- bind_rows(diff_en, diff_vi) %>%
group_by(language, item_kind, domain) %>%
summarise(n_items = n(), mean_b = mean(b), median_b = median(b), .groups = "drop") %>%
arrange(language, mean_b)
category_difficulty %>%
filter(language == "English") %>%
select(-language) %>%
kable(digits = 2, caption = "English: item categories ranked from easiest (most negative mean b) to hardest.") %>%
html_table_width(c(140, 80, 60, 70, 70))
| item_kind | domain | n_items | mean_b | median_b |
|---|---|---|---|---|
| sounds | other | 12 | 0.91 | 0.88 |
| games_routines | other | 25 | 1.38 | 1.48 |
| vehicles | noun | 14 | 1.44 | 1.41 |
| toys | noun | 18 | 1.46 | 1.57 |
| body_parts | noun | 27 | 1.47 | 1.35 |
| animals | noun | 43 | 1.49 | 1.45 |
| food_drink | noun | 68 | 1.76 | 1.78 |
| question_words | other | 7 | 1.81 | 1.73 |
| action_words | verb | 103 | 1.90 | 1.90 |
| descriptive_words | other | 63 | 1.93 | 1.95 |
| outdoor | noun | 31 | 1.96 | 1.92 |
| time_words | other | 12 | 2.02 | 2.04 |
| clothing | noun | 28 | 2.03 | 2.14 |
| people | other | 29 | 2.06 | 2.10 |
| furniture_rooms | noun | 34 | 2.08 | 1.99 |
| household | noun | 50 | 2.10 | 1.99 |
| places | other | 22 | 2.14 | 2.12 |
| pronouns | other | 25 | 2.23 | 2.29 |
| locations | other | 26 | 2.37 | 2.30 |
| quantifiers | other | 17 | 2.46 | 2.44 |
| helping_verbs | other | 21 | 2.54 | 2.45 |
| connecting_words | other | 6 | 2.70 | 2.71 |
category_difficulty %>%
filter(language == "Vietnamese") %>%
select(-language) %>%
kable(digits = 2, caption = "Vietnamese: item categories ranked from easiest (most negative mean b) to hardest.") %>%
html_table_width(c(140, 80, 60, 70, 70))
| item_kind | domain | n_items | mean_b | median_b |
|---|---|---|---|---|
| body_parts | noun | 29 | 0.52 | 0.38 |
| people | other | 29 | 0.67 | 1.02 |
| animals | noun | 46 | 0.96 | 1.09 |
| games_routines | other | 25 | 0.97 | 0.94 |
| question_words | other | 7 | 1.04 | 1.24 |
| vehicles | noun | 15 | 1.09 | 1.03 |
| toys | noun | 19 | 1.13 | 1.18 |
| action_words | verb | 103 | 1.14 | 1.13 |
| household | noun | 58 | 1.22 | 1.18 |
| outdoor | noun | 35 | 1.32 | 1.29 |
| food_drink | noun | 69 | 1.39 | 1.58 |
| furniture_rooms | noun | 33 | 1.42 | 1.39 |
| descriptive_words | other | 62 | 1.42 | 1.37 |
| locations | other | 24 | 1.48 | 1.57 |
| sounds | other | 12 | 1.55 | 2.30 |
| time_words | other | 12 | 1.67 | 1.65 |
| clothing | noun | 28 | 1.69 | 1.81 |
| places | other | 23 | 1.71 | 1.73 |
| quantifiers | other | 16 | 1.76 | 1.66 |
| helping_verbs | other | 12 | 1.97 | 1.99 |
| pronouns | other | 24 | 2.18 | 2.11 |
| connecting_words | other | 6 | 2.28 | 2.21 |
Note: b is on each language’s own,
independently-calibrated scale (2PL models fit separately per language,
each anchored to that language’s own sample mean-0/variance-1
identification) – a b of 1.0 in English and 1.0 in
Vietnamese are not directly comparable magnitudes, only
within-language relative positions are meaningful. Both plots below
therefore give each language its own free b-axis
(scales = "free", not "free_x") so the panels
aren’t visually implying a shared scale that doesn’t exist; see the
cross-language section further down for how the two languages’
difficulties are actually compared (within-language z-scoring, not raw
b).
# Items ordered by difficulty within each language, colored by category -- a
# visual "trajectory" of which categories cluster early (easy/low b) vs. late
# (hard/high b) in the difficulty-implied developmental order.
bind_rows(diff_en, diff_vi) %>%
ggplot(aes(x = difficulty_rank, y = b, color = item_kind)) +
geom_point(alpha = 0.6, size = 1) +
facet_wrap(~language, scales = "free") +
theme_classic() +
theme(legend.position = "bottom") +
guides(color = guide_legend(nrow = 3)) +
xlab("Difficulty rank (1 = easiest)") +
ylab("IRT difficulty (b)")
# Same information as a category-ordered boxplot (categories ordered by
# median difficulty within each language) -- easier to read the within-category
# spread than the scatter above.
bind_rows(diff_en, diff_vi) %>%
mutate(item_kind = fct_reorder(item_kind, b, .fun = median)) %>%
ggplot(aes(x = item_kind, y = b, fill = domain)) +
geom_boxplot(outlier.size = 0.5) +
facet_wrap(~language, ncol = 1, scales = "free") +
coord_flip() +
theme_classic() +
xlab(NULL) + ylab("IRT difficulty (b)")
A second, independent difficulty measure: each item’s empirical age
of acquisition (AoA), estimated the standard
Wordbank/Braginsky-et-al. way – fit produces ~ age as a
logistic regression per item, and take AoA as the age at which the
fitted curve crosses 50% production (-intercept/slope).
This only uses each item’s own production-by-age pattern, with no IRT
model involved, so it’s a useful independent check on whether the IRT
difficulty (b) computed above is actually capturing “when
children learn this word” or something else.
Coverage note, checked empirically: with only ~140
children per language and a ~14-36 month age range, most items’
50%-crossing point falls outside the observed age range
(i.e. the item is already produced by >50% of even the youngest
sampled children, or still produced by <50% of the oldest) –
extrapolating the logistic curve to estimate AoA anyway would be
unreliable, so those items get AoA = NA rather than a
number. In practice this leaves only ~20% of items with an estimable AoA
(146/681 for English in a quick check), so the tables and correlation
below necessarily cover a minority of the full item set, not “all
words.”
# Items with no variance (nobody, or everybody, in the sample produces
# them) can't have a logistic curve fit and get AoA = NA; same for items
# where glm() doesn't converge, returns a non-positive age slope (i.e.
# production doesn't increase with age -- shouldn't happen for real
# vocabulary but guards against a degenerate fit), or -- the main source of
# NAs in practice -- where the fitted 50%-crossing point falls outside the
# sample's observed age range (see the coverage note above: extrapolating
# there would be unreliable rather than merely uncertain).
compute_item_aoa <- function(d_wide, d_demo, d_items) {
age_range <- range(d_demo$age, na.rm = TRUE)
d_long <- d_wide %>%
left_join(d_demo %>% select(response_id, age), by = "response_id") %>%
pivot_longer(cols = -c(response_id, age), names_to = "definition", values_to = "produces")
items <- unique(d_long$definition)
aoa_vals <- map_dbl(items, function(def) {
d_i <- d_long %>% filter(definition == def)
if (sum(d_i$produces) == 0 || sum(d_i$produces) == nrow(d_i)) return(NA_real_)
fit <- tryCatch(glm(produces ~ age, data = d_i, family = binomial), error = function(e) NULL)
if (is.null(fit) || !fit$converged) return(NA_real_)
b0 <- unname(coef(fit)[1]); b1 <- unname(coef(fit)[2])
if (b1 <= 0) return(NA_real_)
aoa_est <- -b0 / b1
if (aoa_est < age_range[1] || aoa_est > age_range[2]) return(NA_real_)
aoa_est
})
tibble(definition = items, aoa = aoa_vals) %>%
left_join(d_items %>% select(definition, item_kind, domain, item_definition), by = "definition")
}
aoa_en <- compute_item_aoa(en_data$d_wide, en_data$d_demo, en_data$d_items)
aoa_vi <- compute_item_aoa(vi_data$d_wide, vi_data$d_demo, vi_data$d_items)
cat("English: AoA estimated for", sum(!is.na(aoa_en$aoa)), "/", nrow(aoa_en), "items\n")
## English: AoA estimated for 146 / 681 items
cat("Vietnamese: AoA estimated for", sum(!is.na(aoa_vi$aoa)), "/", nrow(aoa_vi), "items\n")
## Vietnamese: AoA estimated for 380 / 687 items
aoa_en %>%
filter(!is.na(aoa)) %>%
arrange(aoa) %>%
slice(c(1:15, (n() - 14):n())) %>%
select(item_definition, item_kind, aoa) %>%
kable(digits = 1, caption = "English: 15 earliest- and 15 latest-acquired words by empirical AoA (months).") %>%
html_table_width(c(150, 100, 80))
| item_definition | item_kind | aoa |
|---|---|---|
| meow | sounds | 19.3 |
| bye | games_routines | 19.5 |
| hi | games_routines | 21.6 |
| daddy | people | 22.0 |
| apple | food_drink | 22.9 |
| car | vehicles | 22.9 |
| mommy | people | 23.9 |
| banana | food_drink | 24.2 |
| NA | NA | 24.7 |
| eye | body_parts | 25.0 |
| fish | animals | 26.1 |
| dog | animals | 26.5 |
| bus | vehicles | 27.1 |
| duck | animals | 27.1 |
| nose | body_parts | 27.2 |
| ice | food_drink | 35.5 |
| bottle | household | 35.5 |
| pancake | food_drink | 35.6 |
| wait | action_words | 35.6 |
| green | descriptive_words | 35.6 |
| you | pronouns | 35.7 |
| alligator | animals | 35.7 |
| NA | NA | 35.7 |
| brown | descriptive_words | 35.7 |
| cheese | food_drink | 35.8 |
| box | household | 35.9 |
| sleep | action_words | 35.9 |
| moon | outdoor | 35.9 |
| popsicle | food_drink | 36.0 |
| tummy | body_parts | 36.0 |
aoa_vi %>%
filter(!is.na(aoa)) %>%
arrange(aoa) %>%
slice(c(1:15, (n() - 14):n())) %>%
select(item_definition, item_kind, aoa) %>%
kable(digits = 1, caption = "Vietnamese: 15 earliest- and 15 latest-acquired words by empirical AoA (months).") %>%
html_table_width(c(150, 100, 80))
| item_definition | item_kind | aoa |
|---|---|---|
| mũi | body_parts | 16.1 |
| ông | people | 16.2 |
| NA | NA | 17.3 |
| mèo | animals | 17.7 |
| mắt | body_parts | 17.8 |
| chó | animals | 18.4 |
| cá | food_drink | 18.7 |
| đầu | body_parts | 18.7 |
| tắm | games_routines | 18.8 |
| chuối | food_drink | 19.1 |
| tai | body_parts | 19.2 |
| NA | NA | 19.4 |
| chân | body_parts | 19.6 |
| nước | food_drink | 19.6 |
| tóc | body_parts | 19.8 |
| ngoài | places | 35.5 |
| NA | NA | 35.5 |
| NA | NA | 35.6 |
| NA | NA | 35.6 |
| cao | descriptive_words | 35.6 |
| che | action_words | 35.6 |
| mềm | descriptive_words | 35.7 |
| dây | household | 35.7 |
| làm | helping_verbs | 35.8 |
| bắt | action_words | 35.8 |
| gõ | action_words | 35.9 |
| NA | NA | 35.9 |
| sói | animals | 35.9 |
| hươu | animals | 36.0 |
| nghĩ | action_words | 36.0 |
Correlated separately per language (not pooled) – aoa
(age, months) and b (IRT difficulty, logits) are on
different, non-linearly-related scales (the logistic curve fit for AoA
is itself a nonlinear function of age), so a rank-based Spearman
correlation is used rather than Pearson. This is a within-language
comparison (AoA vs. b for the same item, same language),
unlike Part 3’s cross-language b-vs-b
comparison below, which instead z-scores and uses Pearson since both
quantities there are already on a (standardized) linear scale.
aoa_diff_en <- aoa_en %>% inner_join(diff_en %>% select(definition, b), by = "definition") %>% filter(!is.na(aoa))
aoa_diff_vi <- aoa_vi %>% inner_join(diff_vi %>% select(definition, b), by = "definition") %>% filter(!is.na(aoa))
cat("=== English: AoA vs. IRT difficulty (b) ===\n")
## === English: AoA vs. IRT difficulty (b) ===
print(cor.test(aoa_diff_en$aoa, aoa_diff_en$b, method = "spearman"))
##
## Spearman's rank correlation rho
##
## data: aoa_diff_en$aoa and aoa_diff_en$b
## S = 52284, p-value < 2.2e-16
## alternative hypothesis: true rho is not equal to 0
## sample estimates:
## rho
## 0.8724905
cat("\n=== Vietnamese: AoA vs. IRT difficulty (b) ===\n")
##
## === Vietnamese: AoA vs. IRT difficulty (b) ===
print(cor.test(aoa_diff_vi$aoa, aoa_diff_vi$b, method = "spearman"))
##
## Spearman's rank correlation rho
##
## data: aoa_diff_vi$aoa and aoa_diff_vi$b
## S = 56488, p-value < 2.2e-16
## alternative hypothesis: true rho is not equal to 0
## sample estimates:
## rho
## 0.9780459
bind_rows(aoa_diff_en %>% mutate(language = "English"),
aoa_diff_vi %>% mutate(language = "Vietnamese")) %>%
ggplot(aes(x = aoa, y = b, color = domain)) +
geom_point(alpha = 0.5, size = 1) +
geom_smooth(method = "lm", se = TRUE, color = "grey30") +
facet_wrap(~language, scales = "free") +
theme_classic() +
xlab("Empirical age of acquisition (months)") +
ylab("IRT difficulty (b)")
## `geom_smooth()` using formula = 'y ~ x'
A strong positive correlation in both languages would confirm
b is capturing the same underlying “when is this word
learned” construct as the model-free AoA estimate; a weak or
inconsistent correlation would suggest the IRT model’s difficulty
ordering is picking up something else
(e.g. discrimination/a absorbing some of what a
single-parameter difficulty can’t, since this is the 2PL model, not
Rasch).
item_kind categories are shared between the English and
Vietnamese fields files (unlike individual items, which need the
translation crosswalk used in Part 3), so category-average difficulty
can be compared between languages directly – no item-level translation
matching required. As noted above, raw b isn’t on a shared
scale between the two independently-calibrated models, so
mean_b is z-scored within each language (across
that language’s own categories) before comparing – the same
within-language standardization used for ability_noun in
03_bifactor_noun_verb.Rmd, for the same reason
(independently-calibrated models, arbitrary relative scale). Z-scoring
(unlike converting to ranks) preserves relative spacing, not just order,
so the comparison below uses a Pearson correlation on the z-scores
rather than a rank-based Spearman correlation.
Results: Moderate positive correlation between English and Vietnamese.
category_wide <- category_difficulty %>%
select(language, item_kind, domain, mean_b) %>%
pivot_wider(names_from = language, values_from = mean_b) %>%
filter(!is.na(English), !is.na(Vietnamese)) %>%
mutate(
en_z = as.numeric(scale(English)),
vi_z = as.numeric(scale(Vietnamese))
)
cor.test(category_wide$en_z, category_wide$vi_z, method = "pearson")
##
## Pearson's product-moment correlation
##
## data: category_wide$en_z and category_wide$vi_z
## t = 3.4533, df = 20, p-value = 0.002512
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## 0.2553759 0.8211860
## sample estimates:
## cor
## 0.6111795
# geom_text_repel() needs the ggrepel package (not currently a project
# dependency); if unavailable, swap in plain geom_text(size = 3) -- labels
# will just overlap more.
ggplot(category_wide, aes(x = en_z, y = vi_z, label = item_kind, color = domain)) +
geom_point() +
ggrepel::geom_text_repel(size = 3, show.legend = FALSE) +
geom_smooth(method = "lm", se = TRUE, color = "grey40") +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey70") +
theme_classic() +
xlab("English category difficulty (z-scored within language)") +
ylab("Vietnamese category difficulty (z-scored within language)")
## `geom_smooth()` using formula = 'y ~ x'
## Warning: The following aesthetics were dropped during statistical transformation: label.
## ℹ This can happen when ggplot fails to infer the correct grouping structure in
## the data.
## ℹ Did you forget to specify a `group` aesthetic or to convert a numerical
## variable into a factor?
Categories off the diagonal are inconsistent (easy in one language, hard in the other); categories on the diagonal are candidates for a genuinely conceptually-driven (language-independent) acquisition order.
*Results: sounds, pronouns, connecting words, clothing, time words relatively easier in Vietnamese; body parts, people, question words, action words, household relatively easier in English.
A second, model-free view: for each item, the raw proportion of
children who produce it, in 2-month age bins, aggregated up to
item_kind – the standard Wordbank-style “vocabulary growth
curve,” aggregated by category instead of shown item-by-item.
compute_category_trajectory <- function(d_wide, d_demo, d_items, age_bin_width = 2) {
d_wide %>%
left_join(d_demo %>% select(response_id, age), by = "response_id") %>%
pivot_longer(cols = -c(response_id, age), names_to = "definition", values_to = "produces") %>%
left_join(d_items %>% select(definition, item_kind, domain), by = "definition") %>%
mutate(age_bin = floor(age / age_bin_width) * age_bin_width) %>%
group_by(item_kind, domain, age_bin) %>%
summarise(prop_producing = mean(produces), n_children = n_distinct(response_id), .groups = "drop")
}
traj_en <- compute_category_trajectory(en_data$d_wide, en_data$d_demo, en_data$d_items) %>%
mutate(language = "English")
traj_vi <- compute_category_trajectory(vi_data$d_wide, vi_data$d_demo, vi_data$d_items) %>%
mutate(language = "Vietnamese")
bind_rows(traj_en, traj_vi) %>%
ggplot(aes(x = age_bin, y = prop_producing, color = item_kind)) +
geom_line(alpha = 0.7) +
facet_wrap(~language, ncol = 1) +
theme_classic() +
theme(legend.position = "bottom") +
guides(color = guide_legend(nrow = 3)) +
xlab("Age (months, binned)") +
ylab("Mean proportion of items in category produced")
Categories whose curves rise earliest (leftmost) are learned first; comparing panels shows whether the same categories rise early in both languages (conceptual/maturational account) or whether the rise order differs by language (input-driven account).
To turn “which curve rises earliest” into a number, take each
category’s age-binned prop_producing curve (just computed
above) and find the age at which it crosses 50%, linearly interpolating
between the two bins straddling 0.5 – the category-level analogue of the
item-level AoA computed earlier, but using the already-aggregated
category curve directly rather than needing an item-by-item logistic
fit. As with item-level AoA, a category whose curve never reaches 50%
within the observed age range (still climbing, or – rarer – already
above 50% at the very first bin, i.e. crossed before the observed
window) gets NA rather than an extrapolated guess.
Unlike the IRT b-based category comparison above, this
crossing age is in months – a real, shared unit across
both surveys (the same clock, not an independently-calibrated latent
scale) – so it can be compared directly between languages without
z-scoring; a raw difference in months is directly interpretable.
category_crossing_age <- function(traj_df, threshold = 0.25) {
traj_df %>%
group_by(item_kind, domain) %>%
arrange(age_bin, .by_group = TRUE) %>%
summarise(
crossing_age = {
idx <- which(prop_producing >= threshold)[1]
if (is.na(idx) || idx == 1) {
NA_real_ # never reaches threshold in-range, or already >= threshold at the first observed bin
} else {
age_bin[idx - 1] + (threshold - prop_producing[idx - 1]) /
(prop_producing[idx] - prop_producing[idx - 1]) * (age_bin[idx] - age_bin[idx - 1])
}
},
.groups = "drop"
)
}
category_crossing_en <- category_crossing_age(traj_en) %>% mutate(language = "English")
category_crossing_vi <- category_crossing_age(traj_vi) %>% mutate(language = "Vietnamese")
category_crossing_en %>%
arrange(crossing_age) %>%
select(-language) %>%
kable(digits = 1, caption = "English: categories ordered by age (months) at 50% production.") %>%
html_table_width(c(140, 80, 90))
| item_kind | domain | crossing_age |
|---|---|---|
| sounds | other | 20.3 |
| games_routines | other | 21.2 |
| body_parts | noun | 21.5 |
| vehicles | noun | 22.8 |
| animals | noun | 23.3 |
| toys | noun | 25.6 |
| food_drink | noun | 28.4 |
| people | other | 28.8 |
| places | other | 29.0 |
| outdoor | noun | 29.4 |
| clothing | noun | 29.5 |
| pronouns | other | 29.9 |
| furniture_rooms | noun | 29.9 |
| NA | NA | 30.1 |
| question_words | other | 30.4 |
| descriptive_words | other | 30.5 |
| action_words | verb | 30.8 |
| time_words | other | 34.6 |
| connecting_words | other | 35.6 |
| household | noun | 35.6 |
| helping_verbs | other | NA |
| locations | other | NA |
| quantifiers | other | NA |
category_crossing_vi %>%
arrange(crossing_age) %>%
select(-language) %>%
kable(digits = 1, caption = "Vietnamese: categories ordered by age (months) at 50% production.") %>%
html_table_width(c(140, 80, 90))
| item_kind | domain | crossing_age |
|---|---|---|
| sounds | other | 14.6 |
| body_parts | noun | 17.6 |
| clothing | noun | 19.3 |
| food_drink | noun | 20.2 |
| animals | noun | 20.3 |
| toys | noun | 20.9 |
| action_words | verb | 21.2 |
| outdoor | noun | 21.2 |
| furniture_rooms | noun | 21.2 |
| household | noun | 22.1 |
| quantifiers | other | 22.4 |
| places | other | 22.5 |
| time_words | other | 22.6 |
| locations | other | 22.7 |
| descriptive_words | other | 22.9 |
| helping_verbs | other | 23.1 |
| NA | NA | 23.8 |
| pronouns | other | 24.0 |
| connecting_words | other | 25.9 |
| games_routines | other | NA |
| people | other | NA |
| question_words | other | NA |
| vehicles | noun | NA |
category_crossing_wide <- bind_rows(category_crossing_en, category_crossing_vi) %>%
select(language, item_kind, domain, crossing_age) %>%
pivot_wider(names_from = language, values_from = crossing_age) %>%
filter(!is.na(English), !is.na(Vietnamese)) %>%
mutate(diff_vi_minus_en = Vietnamese - English)
cor.test(category_crossing_wide$English, category_crossing_wide$Vietnamese, method = "pearson")
##
## Pearson's product-moment correlation
##
## data: category_crossing_wide$English and category_crossing_wide$Vietnamese
## t = 4.9217, df = 14, p-value = 0.000225
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## 0.4961607 0.9262596
## sample estimates:
## cor
## 0.7960724
category_crossing_wide %>%
arrange(desc(abs(diff_vi_minus_en))) %>%
kable(digits = 1, caption = "Category 25%-production age (months), English vs. Vietnamese, sorted by |difference|.") %>%
html_table_width(c(140, 60, 90, 90, 90))
| item_kind | domain | English | Vietnamese | diff_vi_minus_en |
|---|---|---|---|---|
| household | noun | 35.6 | 22.1 | -13.5 |
| time_words | other | 34.6 | 22.6 | -12.0 |
| clothing | noun | 29.5 | 19.3 | -10.2 |
| connecting_words | other | 35.6 | 25.9 | -9.7 |
| action_words | verb | 30.8 | 21.2 | -9.7 |
| furniture_rooms | noun | 29.9 | 21.2 | -8.7 |
| food_drink | noun | 28.4 | 20.2 | -8.2 |
| outdoor | noun | 29.4 | 21.2 | -8.2 |
| descriptive_words | other | 30.5 | 22.9 | -7.6 |
| places | other | 29.0 | 22.5 | -6.5 |
| NA | NA | 30.1 | 23.8 | -6.3 |
| pronouns | other | 29.9 | 24.0 | -5.9 |
| sounds | other | 20.3 | 14.6 | -5.7 |
| toys | noun | 25.6 | 20.9 | -4.6 |
| body_parts | noun | 21.5 | 17.6 | -3.9 |
| animals | noun | 23.3 | 20.3 | -3.0 |
ggplot(category_crossing_wide, aes(x = English, y = Vietnamese, label = item_kind, color = domain)) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey70") +
geom_point() +
ggrepel::geom_text_repel(size = 3, show.legend = FALSE) +
theme_classic() +
xlab("English: age (months) at 50% production") +
ylab("Vietnamese: age (months) at 50% production")
## Warning: Removed 1 row containing missing values or values outside the scale range
## (`geom_text_repel()`).
A dumbbell plot of the same numbers, ordered by how far apart the two languages are for that category – categories near the top show the largest EN-VI gap in learning order; the dashed connecting line points from whichever language is earlier to whichever is later.
category_crossing_wide %>%
mutate(item_kind = fct_reorder(item_kind, abs(diff_vi_minus_en))) %>%
ggplot(aes(y = item_kind)) +
geom_segment(aes(x = English, xend = Vietnamese, y = item_kind, yend = item_kind),
color = "grey70", linewidth = 0.6) +
geom_point(aes(x = English, color = "English"), size = 2.5) +
geom_point(aes(x = Vietnamese, color = "Vietnamese"), size = 2.5) +
theme_classic() +
xlab("Age (months) at 50% production") + ylab(NULL) +
labs(color = NULL)
Following Kachergis, Francis, & Frank (2022, Estimating demographic bias on tests of children’s early vocabulary), we test items for DIF along three demographic dimensions, separately for English and Vietnamese:
ppl_1_edu) as an
SES proxy – analogous to the paper’s maternal-education splitn_languages_group, derived above: 2-or-fewer
vs. more-than-2, based on how many of
language_1..language_4 are non-missing) – not
in the original paper, but a natural bilingual-CDI analogue: does
knowing more languages come at a cost (or benefit) to vocabulary
within either single language?02_IRT_analysis_en.Rmd/02_IRT_analysis_vi.Rmd,
models/{en,vi}/mod_1pl.Rds), fit without regard to
demographic group, gives each child a baseline ability
theta. Regressing theta ~ age_c * group per
demographic dimension characterizes the overall size of the
demographic effect (paper’s “Demographic effects in the baseline Rasch
model”). This step is just for the overall-effect-size context, not the
item-level DIF test.mirt() fit fixes that
group’s latent mean at 0 by default, so fitting each group separately
reproduces the paper’s identification assumption (“the mean language
ability in each group is the same… thus pushing all between-group
variation into the item [parameters]”). Rasch (1PL, a fixed
at 1 for every item) is used throughout, matching the paper – it’s the
simplest model and the most robust choice at our modest per-group sample
sizes (~50-100 children, far smaller than the paper’s ~2000+ per group),
and with Rasch a group difference is unambiguously a difficulty
(b) shift (“uniform DIF”).b_diff = b_group1 - b_group2 is the
difficulty difference between groups – the “uniform DIF” signal, and the
only kind Rasch can detect (discrimination is fixed at 1 for every item,
in every group). We summarize its distribution (median/mean/SD, count of
items favoring each group) and visualize it as an ordered dot plot – a
simplified, point-estimate-only version of the paper’s GLIMMER plots
(which additionally draw 10,000 imputations from each item’s parameter
covariance matrix to show uncertainty; that needs SE = TRUE
when fitting mirt(), which is slower, so it’s omitted here
– see the note in dif_dotplot() below).|b_diff|
exclusion thresholds (in SD units, 0.25 to 3), drop the most extreme
items, refit a single-group Rasch model on the retained items
(full sample, not split by group), and re-run the
theta ~ age_c * group regression – replicating the paper’s
Figure 3 (does trimming biased items shrink the estimated demographic
effect?).item_kind/
domain for qualitative inspection, as in the paper’s Figure
4.ppl_1_edu high/low split. Rather than
the paper’s “any college” cutoff (which here would give a lopsided 130
high / 9 low split, too few “low” children to fit reliably),
edu_group below is a median split on an 8-level ordinal
scale (grade-8-or-less to master’s/doctoral, EN/VI label pairs collapsed
to the same rank). Verified against both data files: identical
distribution in the English and Vietnamese CDI (shared demographic
field), median rank 6 (“Bachelor’s degree”), giving 109 high / 27 low.
edu_na_levels (“Không rõ”/“Not sure”) are treated as
missing, not “low”, since they’re non-response rather than a reported
attainment level.sex level names/casing.
sex_group below assumes levels exactly
"M"/"F" (confirmed against the raw data). If
that’s ever wrong – e.g. after a data refresh changes the coding –
factor(sex, levels = c("M","F")) would silently produce
NA for every row and the whole sex-DIF analysis would
silently run on zero data. The diagnostic table() calls in
add_dif_groups() below are there specifically to catch this
before it happens quietly – check their output before trusting anything
downstream.fit_group_irt()
and prune_and_refit() subset d_mat by row
position to match d_demo’s row order (there’s no
join key back to d_mat’s rows). This matches how
d_mat/d_demo are constructed in
load_lang_data() above (same file, same
read_csv() row order) and how the rest of this project’s
Rmds rely on the same invariant – but it would silently break if
d_demo were ever re-sorted without re-sorting
d_mat to match.ggrepel is used in Part 1’s plot only;
not needed here.# Ordinal 8-level parent-1 education scale (EN/VI label pairs collapsed to
# the same rank), used for a median split into edu_group. edu_na_levels
# ("Không rõ"/"Not sure") are treated as missing, not "low", since they're
# non-response rather than a reported attainment level.
#
# Verified against both data files (identical distribution in the English
# and Vietnamese CDI, since ppl_1_edu is a shared demographic field): n =
# 136 non-missing / 6 missing, median rank = 6 ("Four-year college
# Bachelor's degree" / "Có bằng cử nhân bốn năm"), giving a 109 (high) / 27
# (low) split at the median -- far more balanced for per-group model fitting
# than a naive "any college" cutoff, which left only 6-9 children in "low".
edu_levels_ordered <- list(
"1_grade8_or_less" = c("Lớp 8 hoặc ít hơn"),
"2_some_high_school" = c("Vài năm trung học phổ thông (cấp 3)"),
"3_high_school_grad" = c("Hoàn thành trung học phổ thông (cấp 3)", "High school graduate or GED"),
"4_some_college" = c("One or more years of college, no degree", "Vài năm cao đẳng, không có bằng"),
"5_two_year_college" = c("Two-year college degree or vocational degree",
"Có bằng cao đẳng hoặc tốt nghiệp trường dạy nghề"),
"6_bachelors" = c("Four-year college Bachelor's degree", "Có bằng cử nhân bốn năm"),
"7_some_grad_school" = c("Vài năm cao học", "Some graduate education"),
"8_masters_or_doctoral" = c("Master's or doctoral degree", "Có bằng thạc sĩ, tiến sĩ hoặc tương tự")
)
edu_na_levels <- c("Không rõ", "Not sure")
edu_rank_lookup <- setNames(rep(seq_along(edu_levels_ordered), lengths(edu_levels_ordered)),
unlist(edu_levels_ordered))
edu_median_rank <- 6 # see note above; re-check if the sample changes
add_dif_groups <- function(d_demo, lang_label) {
cat("--", lang_label, "sex distribution --\n")
print(table(d_demo$sex, useNA = "always"))
cat("\n--", lang_label, "ppl_1_edu distribution --\n")
print(table(d_demo$ppl_1_edu, useNA = "always"))
cat("\n--", lang_label, "n_languages_household distribution --\n")
print(table(d_demo$n_languages_household, useNA = "always"))
edu_rank <- if_else(d_demo$ppl_1_edu %in% edu_na_levels,
NA_integer_, unname(edu_rank_lookup[d_demo$ppl_1_edu]))
d_demo %>%
mutate(
sex_group = factor(sex, levels = c("M", "F")),
edu_rank = edu_rank,
edu_group = factor(if_else(edu_rank >= edu_median_rank, "high", "low"),
levels = c("low", "high")),
lang_group = n_languages_group
)
}
en_data$d_demo <- add_dif_groups(en_data$d_demo, "English")
## -- English sex distribution --
##
## F M <NA>
## 65 77 0
##
## -- English ppl_1_edu distribution --
##
## Có bằng cao đẳng hoặc tốt nghiệp trường dạy nghề
## 6
## Có bằng cử nhân bốn năm
## 38
## Có bằng thạc sĩ, tiến sĩ hoặc tương tự
## 21
## Four-year college Bachelor's degree
## 19
## High school graduate or GED
## 2
## Hoàn thành trung học phổ thông (cấp 3)
## 2
## Không rõ
## 2
## Lớp 8 hoặc ít hơn
## 1
## Master's or doctoral degree
## 28
## Not sure
## 1
## One or more years of college, no degree
## 6
## Some graduate education
## 1
## Two-year college degree or vocational degree
## 2
## Vài năm cao đẳng, không có bằng
## 7
## Vài năm cao học
## 2
## Vài năm trung học phổ thông (cấp 3)
## 1
## <NA>
## 3
##
## -- English n_languages_household distribution --
##
## 1 2 3 4 <NA>
## 11 89 34 8 0
vi_data$d_demo <- add_dif_groups(vi_data$d_demo, "Vietnamese")
## -- Vietnamese sex distribution --
##
## F M <NA>
## 65 77 0
##
## -- Vietnamese ppl_1_edu distribution --
##
## Có bằng cao đẳng hoặc tốt nghiệp trường dạy nghề
## 6
## Có bằng cử nhân bốn năm
## 38
## Có bằng thạc sĩ, tiến sĩ hoặc tương tự
## 21
## Four-year college Bachelor's degree
## 19
## High school graduate or GED
## 2
## Hoàn thành trung học phổ thông (cấp 3)
## 2
## Không rõ
## 2
## Lớp 8 hoặc ít hơn
## 1
## Master's or doctoral degree
## 28
## Not sure
## 1
## One or more years of college, no degree
## 6
## Some graduate education
## 1
## Two-year college degree or vocational degree
## 2
## Vài năm cao đẳng, không có bằng
## 7
## Vài năm cao học
## 2
## Vài năm trung học phổ thông (cấp 3)
## 1
## <NA>
## 3
##
## -- Vietnamese n_languages_household distribution --
##
## 1 2 3 4 <NA>
## 11 89 34 8 0
# Baseline (single-group, whole sample) Rasch ability regressed against age
# and a demographic group -- the paper's "Demographic effects in the
# baseline Rasch model" analysis. Always uses the baseline 1PL model
# (models/{en,vi}/mod_1pl.Rds), since this step is just about the overall
# demographic effect size, not item-level DIF.
# Assumes fscores(mod_1pl) returns one row per child in the same order as
# d_demo (true if mod_1pl.Rds was fit on the same file/row order as
# load_lang_data() reads here).
baseline_group_effect <- function(mod_1pl, d_demo, group_var) {
theta <- fscores(mod_1pl, method = "MAP")[, 1]
d <- d_demo %>%
mutate(theta = theta, age_c = age - mean(age, na.rm = TRUE)) %>%
filter(!is.na(.data[[group_var]]))
fit <- lm(as.formula(paste0("theta ~ age_c * ", group_var)), data = d)
list(data = d, fit = fit)
}
# Items with zero variance within a given row-subset (nobody, or everybody,
# in that subset endorses it) carry no information to estimate a difficulty
# from and make mirt() error out ("only one response category and cannot be
# estimated"). This happens routinely once the sample is split into small
# demographic subgroups (some rare/near-universal CDI words end up perfectly
# 0/1 within a ~27-100-child subgroup purely by chance, even though they
# have variance in the full ~140-child sample). Returns the column names of
# d_mat_subset that have at least one 0 and at least one 1.
usable_items <- function(d_mat_subset) {
col_sums <- colSums(d_mat_subset)
colnames(d_mat_subset)[col_sums > 0 & col_sums < nrow(d_mat_subset)]
}
# Fits a single-group Rasch model separately within each level of group_var
# (subsetting d_mat by row position via which(d_demo[[group_var]] == g)),
# returning one item-parameter tibble per group. Each fit has that
# subgroup's own latent mean fixed at 0, reproducing the paper's
# identification assumption. Items with zero variance *within that specific
# group* are dropped before fitting (see usable_items()); different groups
# can end up dropping different items, which is fine -- compute_dif() below
# only compares items present in both groups' output (via inner_join).
fit_group_irt <- function(d_mat, d_demo, group_var, ncycles = 2000, seed = 1234) {
groups <- levels(d_demo[[group_var]])
out <- list()
for (g in groups) {
idx <- which(d_demo[[group_var]] == g)
d_mat_g <- d_mat[idx, , drop = FALSE]
keep <- usable_items(d_mat_g)
if (length(keep) < ncol(d_mat_g)) {
message(sprintf("fit_group_irt(): dropping %d zero-variance item(s) for group '%s' (n = %d)",
ncol(d_mat_g) - length(keep), g, length(idx)))
}
d_mat_g <- d_mat_g[, keep, drop = FALSE]
set.seed(seed)
mod_g <- mirt(d_mat_g, 1, itemtype = "Rasch",
technical = list(NCYCLES = ncycles), verbose = FALSE)
cf <- coef(mod_g, simplify = TRUE, IRTpars = TRUE)$items
out[[g]] <- tibble(definition = rownames(cf), b = as.numeric(cf[, "b"]), n = length(idx))
}
out
}
# Item-level difficulty differences (group1 - group2) between two per-group
# Rasch fits from fit_group_irt(), with item_kind/domain attached and
# outlier flags at a given SD threshold. b_diff is the "uniform DIF" signal
# (positive b_diff means group1's b is higher/harder, i.e. the item is
# easier for group2) -- the only kind of DIF Rasch can detect, since
# discrimination is fixed at 1 for every item, in every group.
compute_dif <- function(group_fits, group1, group2, d_items, sd_threshold = 2.25) {
d <- group_fits[[group1]] %>%
select(definition, b1 = b) %>%
inner_join(group_fits[[group2]] %>% select(definition, b2 = b), by = "definition") %>%
mutate(b_diff = b1 - b2) %>%
left_join(d_items %>% select(definition, item_kind, domain, item_definition), by = "definition")
m_b <- mean(d$b_diff); s_b <- sd(d$b_diff)
d %>% mutate(
b_diff_z = (b_diff - m_b) / s_b,
extreme = abs(b_diff_z) > sd_threshold,
favors = if_else(b_diff > 0, group2, group1)
)
}
# Simplified GLIMMER-style plot: the top_n most extreme items ordered by
# |b_diff|, colored by which group finds them easier. The original GLIMMER
# additionally draws 10,000 imputations per item from the item parameter
# covariance matrix to show uncertainty around each b_diff -- that needs
# SE = TRUE when fitting mirt() in fit_group_irt() (slower) plus
# vcov(mod_g) per item; omitted here for a faster point-estimate-only
# version, but worth adding if the point estimates below suggest clusters
# worth confirming with uncertainty.
dif_dotplot <- function(dif_tab, group1, group2, top_n = 20) {
dif_tab %>%
slice_max(order_by = abs(b_diff), n = top_n) %>%
mutate(item_definition = fct_reorder(item_definition, b_diff)) %>%
ggplot(aes(x = b_diff, y = item_definition, color = favors)) +
geom_vline(xintercept = 0, linetype = "dashed", color = "grey50") +
geom_point(size = 2) +
theme_classic() +
labs(x = paste0("Difficulty difference (", group1, " b - ", group2, " b)"),
y = NULL, color = "Favors")
}
# Pruning sweep: at each SD threshold, drop items with |b_diff_z| > threshold,
# refit a single-group Rasch model on the RETAINED items using the full
# (both-groups-combined) sample, and re-run the theta ~ age_c * group
# regression -- replicating the paper's Figure 3.
prune_and_refit <- function(d_mat, d_demo, dif_tab, group_var, thresholds = seq(0.25, 3, by = 0.25),
ncycles = 2000, seed = 1234) {
valid_idx <- which(!is.na(d_demo[[group_var]]))
d_demo_valid <- d_demo[valid_idx, ]
d_mat_valid <- d_mat[valid_idx, , drop = FALSE]
usable <- usable_items(d_mat_valid) # see fit_group_irt()'s note; unlikely but possible even in the combined sample
map_dfr(thresholds, function(thr) {
keep <- intersect(dif_tab$definition[abs(dif_tab$b_diff_z) <= thr], colnames(d_mat_valid))
keep <- intersect(keep, usable)
if (length(keep) < 10) {
return(tibble(threshold = thr, n_excluded = ncol(d_mat_valid) - length(keep),
beta_group = NA_real_, p_value = NA_real_))
}
set.seed(seed)
mod_thr <- mirt(d_mat_valid[, keep], 1, itemtype = "Rasch",
technical = list(NCYCLES = ncycles), verbose = FALSE)
theta <- fscores(mod_thr, method = "MAP")[, 1]
d <- d_demo_valid %>% mutate(theta = theta, age_c = age - mean(age, na.rm = TRUE))
fit <- lm(as.formula(paste0("theta ~ age_c * ", group_var)), data = d)
cf <- summary(fit)$coefficients
group_row <- grep(paste0("^", group_var), rownames(cf), value = TRUE)[1]
tibble(threshold = thr, n_excluded = ncol(d_mat_valid) - length(keep),
beta_group = cf[group_row, "Estimate"], p_value = cf[group_row, "Pr(>|t|)"])
})
}
Loops over all 6 combinations (2 languages x 3 demographics), caching
each combination’s per-group Rasch fits and pruning sweep to
models/{en,vi}/dif_<group_var>_Rasch_{fits,prune}.Rds
(same on-disk caching convention as bootstrap_noun_ci() in
03_bifactor_noun_verb.Rmd – delete a cache file to force a
recompute for that combination). Uses results='asis' so
each iteration can print its own subheading, tables, and plots.
dif_specs <- tribble(
~language, ~group_var, ~group1, ~group2,
"en", "sex_group", "M", "F",
"vi", "sex_group", "M", "F",
"en", "edu_group", "low", "high",
"vi", "edu_group", "low", "high",
"en", "lang_group", "2_or_fewer", "more_than_2",
"vi", "lang_group", "2_or_fewer", "more_than_2"
)
lang_data <- list(en = en_data, vi = vi_data)
lang_label <- c(en = "English", vi = "Vietnamese")
# mod_1pl.Rds was written via save(file = ..., "mod_1pl", "fscores_1pl", "coefs_1pl")
# in 02_IRT_analysis_en.Rmd/02_IRT_analysis_vi.Rmd -- a multi-object save()
# file, not a single-object saveRDS() one, so it needs load() (not
# readRDS(), which errors with "unknown input format" on this file) and
# each load() populates mod_1pl/fscores_1pl/coefs_1pl into the environment,
# overwriting the previous language's copy -- hence capturing mod_1pl into a
# renamed variable immediately after each load(), same as mod_2pl_en/
# mod_2pl_vi above.
load("models/en/mod_1pl.Rds"); mod_1pl_en <- mod_1pl; rm(mod_1pl)
load("models/vi/mod_1pl.Rds"); mod_1pl_vi <- mod_1pl; rm(mod_1pl)
rm(fscores_1pl, coefs_1pl)
mod_1pl <- list(en = mod_1pl_en, vi = mod_1pl_vi)
dif_results <- list()
for (i in seq_len(nrow(dif_specs))) {
spec <- dif_specs[i, ]
ld <- lang_data[[spec$language]]
key <- paste(spec$language, spec$group_var, sep = "_")
cat("\n\n### ", lang_label[[spec$language]], ": ", spec$group_var, "\n\n", sep = "")
base <- baseline_group_effect(mod_1pl[[spec$language]], ld$d_demo, spec$group_var)
cat("Baseline demographic effect (`theta ~ age_c * group`):\n\n")
print(knitr::kable(broom::tidy(base$fit), digits = 3))
fits_cache <- paste0("models/", spec$language, "/dif_", spec$group_var, "_Rasch_fits.Rds")
if (!file.exists(fits_cache)) {
fits <- fit_group_irt(ld$d_mat, ld$d_demo, spec$group_var)
saveRDS(fits, fits_cache)
} else {
fits <- readRDS(fits_cache)
}
dif_tab <- compute_dif(fits, spec$group1, spec$group2, ld$d_items)
dif_results[[key]] <- dif_tab
cat("\n\nMedian b_diff (", spec$group1, " b - ", spec$group2, " b): ",
round(median(dif_tab$b_diff), 2),
" (mean ", round(mean(dif_tab$b_diff), 2), ", sd ", round(sd(dif_tab$b_diff), 2), ") | ",
sum(dif_tab$b_diff > 0), "/", nrow(dif_tab), " items easier for ", spec$group2, "\n\n", sep = "")
print(
ggplot(dif_tab, aes(x = b_diff)) +
geom_histogram(bins = 40) +
geom_vline(xintercept = 0, linetype = "dashed") +
theme_classic() +
xlab(paste0("Difficulty difference (", spec$group1, " b - ", spec$group2, " b)"))
)
print(dif_dotplot(dif_tab, spec$group1, spec$group2))
cat("\n\n**Items with extreme (>2.25 SD) difficulty difference:**\n\n")
print(
dif_tab %>%
filter(extreme) %>%
arrange(b_diff) %>%
select(item_definition, item_kind, domain, b_diff, b_diff_z, favors) %>%
knitr::kable(digits = 2)
)
prune_cache <- paste0("models/", spec$language, "/dif_", spec$group_var, "_Rasch_prune.Rds")
if (!file.exists(prune_cache)) {
prune_tab <- prune_and_refit(ld$d_mat, ld$d_demo, dif_tab, spec$group_var)
saveRDS(prune_tab, prune_cache)
} else {
prune_tab <- readRDS(prune_cache)
}
print(
ggplot(prune_tab, aes(x = threshold, y = beta_group)) +
geom_point() + geom_line() +
theme_classic() +
xlab("Exclusion threshold (SD)") +
ylab(paste0(spec$group_var, " coefficient (beta), ", lang_label[[spec$language]]))
)
}
Baseline demographic effect (theta ~ age_c * group):
| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | -1.711 | 0.395 | -4.330 | 0.000 |
| age_c | 0.116 | 0.070 | 1.663 | 0.099 |
| sex_groupF | 0.308 | 0.585 | 0.527 | 0.599 |
| age_c:sex_groupF | 0.075 | 0.099 | 0.758 | 0.450 |
Median b_diff (M b - F b): 0.68 (mean 0.58, sd 0.86) | 528/676 items easier for F
Items with extreme (>2.25 SD) difficulty difference:
| item_definition | item_kind | domain | b_diff | b_diff_z | favors |
|---|---|---|---|---|---|
| myself | pronouns | other | -2.55 | -3.64 | M |
| they | pronouns | other | -2.33 | -3.37 | M |
| tricycle | vehicles | noun | -2.33 | -3.37 | M |
| bump | action_words | verb | -1.84 | -2.81 | M |
| radio | household | noun | -1.81 | -2.78 | M |
| beside | locations | other | -1.81 | -2.78 | M |
| on top of | locations | other | -1.81 | -2.78 | M |
| our | pronouns | other | -1.81 | -2.78 | M |
| gum | food_drink | noun | -1.52 | -2.44 | M |
| could | helping_verbs | other | -1.52 | -2.44 | M |
| about | locations | other | -1.52 | -2.44 | M |
| circus | places | other | -1.52 | -2.44 | M |
| downtown | places | other | -1.52 | -2.44 | M |
| their | pronouns | other | -1.52 | -2.44 | M |
| these | pronouns | other | -1.52 | -2.44 | M |
| yourself | pronouns | other | -1.52 | -2.44 | M |
| tonight | time_words | other | -1.43 | -2.34 | M |
| coke | food_drink | noun | -1.43 | -2.34 | M |
| TV | furniture_rooms | noun | 2.61 | 2.35 | F |
| owie/ boo boo | body_parts | noun | 2.61 | 2.36 | F |
| swing | action_words | verb | 2.69 | 2.45 | F |
| allgone | descriptive_words | other | 2.69 | 2.45 | F |
| sister | people | other | 2.69 | 2.45 | F |
| sky | outdoor | noun | 2.79 | 2.57 | F |
| finish | action_words | verb | 2.88 | 2.66 | F |
Baseline demographic effect (theta ~ age_c * group):
| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | -0.551 | 0.241 | -2.283 | 0.024 |
| age_c | 0.190 | 0.043 | 4.453 | 0.000 |
| sex_groupF | -0.109 | 0.357 | -0.305 | 0.761 |
| age_c:sex_groupF | 0.013 | 0.060 | 0.217 | 0.829 |
Median b_diff (M b - F b): -0.75 (mean -0.76, sd 0.6) | 62/687 items easier for F
Items with extreme (>2.25 SD) difficulty difference:
| item_definition | item_kind | domain | b_diff | b_diff_z | favors |
|---|---|---|---|---|---|
| đài | household | noun | -3.39 | -4.37 | M |
| cái của họ | pronouns | other | -2.51 | -2.91 | M |
| để tôi | helping_verbs | other | -2.51 | -2.91 | M |
| mái nhà | outdoor | noun | -2.39 | -2.71 | M |
| cô ấy | pronouns | other | -2.37 | -2.67 | M |
| dã ngoại | places | other | -2.37 | -2.67 | M |
| găng tay hở ngón | clothing | noun | -2.29 | -2.53 | M |
| trước | time_words | other | -2.26 | -2.49 | M |
| vòi tưới | outdoor | noun | -2.26 | -2.49 | M |
| quần áo mùa đông | clothing | noun | -2.21 | -2.40 | M |
| bắp rang bơ | food_drink | noun | -2.12 | -2.26 | M |
| ầm | descriptive_words | other | 0.60 | 2.27 | F |
| cá | animals | noun | 0.63 | 2.31 | F |
| tóe | action_words | verb | 0.68 | 2.40 | F |
| váy | clothing | noun | 0.73 | 2.48 | F |
| ghế dài | furniture_rooms | noun | 0.75 | 2.51 | F |
| mua sắm | games_routines | other | 0.80 | 2.59 | F |
| nước có ga | food_drink | noun | 0.92 | 2.81 | F |
| rết | animals | noun | 1.02 | 2.96 | F |
Baseline demographic effect (theta ~ age_c * group):
| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | -1.541 | 0.691 | -2.230 | 0.027 |
| age_c | 0.090 | 0.121 | 0.747 | 0.456 |
| edu_grouphigh | -0.006 | 0.766 | -0.007 | 0.994 |
| age_c:edu_grouphigh | 0.070 | 0.133 | 0.529 | 0.598 |
Median b_diff (low b - high b): 1.51 (mean 1.5, sd 0.9) | 634/667 items easier for high
Items with extreme (>2.25 SD) difficulty difference:
| item_definition | item_kind | domain | b_diff | b_diff_z | favors |
|---|---|---|---|---|---|
| person | people | other | -3.18 | -5.21 | low |
| on top of | locations | other | -1.24 | -3.05 | low |
| porch | furniture_rooms | noun | -1.11 | -2.91 | low |
| woods | places | other | -0.93 | -2.71 | low |
| sweep | action_words | verb | -0.85 | -2.62 | low |
| dump | action_words | verb | -0.85 | -2.62 | low |
| his | pronouns | other | -0.85 | -2.62 | low |
| farm | places | other | -0.80 | -2.56 | low |
| country | places | other | -0.71 | -2.46 | low |
| soda/pop | food_drink | noun | -0.65 | -2.39 | low |
| every | quantifiers | other | -0.65 | -2.39 | low |
| people | people | other | -0.65 | -2.39 | low |
| him | pronouns | other | -0.65 | -2.39 | low |
| cereal/granola | food_drink | noun | 3.65 | 2.40 | high |
| peanut butter | food_drink | noun | 3.65 | 2.40 | high |
| swing | action_words | verb | 4.21 | 3.02 | high |
| cheese | food_drink | noun | 4.27 | 3.09 | high |
Baseline demographic effect (theta ~ age_c * group):
| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | -0.850 | 0.425 | -2.002 | 0.047 |
| age_c | 0.105 | 0.074 | 1.420 | 0.158 |
| edu_grouphigh | 0.317 | 0.470 | 0.674 | 0.502 |
| age_c:edu_grouphigh | 0.118 | 0.082 | 1.451 | 0.149 |
Median b_diff (low b - high b): 0.4 (mean 0.46, sd 0.72) | 506/683 items easier for high
Items with extreme (>2.25 SD) difficulty difference:
| item_definition | item_kind | domain | b_diff | b_diff_z | favors |
|---|---|---|---|---|---|
| gà tây | animals | noun | -1.70 | -3.01 | low |
| nhà | places | other | -1.37 | -2.56 | low |
| cái của họ | pronouns | other | -1.24 | -2.37 | low |
| ghế bành | furniture_rooms | noun | -1.24 | -2.37 | low |
| mọi cái | quantifiers | other | -1.24 | -2.37 | low |
| không phải | quantifiers | other | -1.22 | -2.35 | low |
| rết | animals | noun | -1.21 | -2.34 | low |
| nai | animals | noun | -1.21 | -2.33 | low |
| phòng | furniture_rooms | noun | -1.18 | -2.28 | low |
| bữa tiệc | places | other | -1.17 | -2.27 | low |
| be be | sounds | other | -1.16 | -2.26 | low |
| đồng xu | household | noun | 2.13 | 2.31 | high |
| khăn tắm | household | noun | 2.15 | 2.34 | high |
| giày thể thao | clothing | noun | 2.15 | 2.35 | high |
| thìa | household | noun | 2.21 | 2.42 | high |
| bơ đậu phộng | food_drink | noun | 2.25 | 2.49 | high |
| ầm | descriptive_words | other | 2.25 | 2.49 | high |
| chậm | descriptive_words | other | 2.32 | 2.59 | high |
| người tuyết | outdoor | noun | 2.32 | 2.59 | high |
| cốc | household | noun | 2.35 | 2.63 | high |
| máy cắt cỏ | outdoor | noun | 2.38 | 2.66 | high |
| chổi lau sàn | household | noun | 2.81 | 3.27 | high |
| máy kéo | vehicles | noun | 2.91 | 3.41 | high |
| băng dính | household | noun | 3.01 | 3.54 | high |
Baseline demographic effect (theta ~ age_c * group):
| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | -2.006 | 0.338 | -5.932 | 0.000 |
| age_c | 0.146 | 0.054 | 2.715 | 0.007 |
| lang_groupmore_than_2 | 1.417 | 0.627 | 2.259 | 0.025 |
| age_c:lang_groupmore_than_2 | 0.069 | 0.121 | 0.568 | 0.571 |
Median b_diff (2_or_fewer b - more_than_2 b): 0.53 (mean 0.48, sd 0.85) | 505/677 items easier for more_than_2
Items with extreme (>2.25 SD) difficulty difference:
| item_definition | item_kind | domain | b_diff | b_diff_z | favors |
|---|---|---|---|---|---|
| for | locations | other | -2.34 | -3.31 | 2_or_fewer |
| at | locations | other | -2.34 | -3.31 | 2_or_fewer |
| church | places | other | -2.34 | -3.31 | 2_or_fewer |
| the | quantifiers | other | -2.34 | -3.31 | 2_or_fewer |
| think | action_words | verb | -2.11 | -3.04 | 2_or_fewer |
| not | quantifiers | other | -1.79 | -2.66 | 2_or_fewer |
| are | helping_verbs | other | -1.61 | -2.45 | 2_or_fewer |
| is | helping_verbs | other | -1.61 | -2.45 | 2_or_fewer |
| tear | action_words | verb | -1.57 | -2.40 | 2_or_fewer |
| tiny | descriptive_words | other | -1.57 | -2.40 | 2_or_fewer |
| with | locations | other | -1.57 | -2.40 | 2_or_fewer |
| him | pronouns | other | -1.57 | -2.40 | 2_or_fewer |
| yourself | pronouns | other | -1.57 | -2.40 | 2_or_fewer |
| their | pronouns | other | -1.57 | -2.40 | 2_or_fewer |
| any | quantifiers | other | -1.57 | -2.40 | 2_or_fewer |
| same | quantifiers | other | -1.57 | -2.40 | 2_or_fewer |
| don’t | helping_verbs | other | -1.52 | -2.35 | 2_or_fewer |
| raisin | food_drink | noun | 2.42 | 2.27 | more_than_2 |
| swing | action_words | verb | 2.60 | 2.48 | more_than_2 |
| this little piggy | games_routines | other | 2.87 | 2.80 | more_than_2 |
| vagina | body_parts | noun | 2.93 | 2.87 | more_than_2 |
| patty cake | games_routines | other | 3.22 | 3.20 | more_than_2 |
| nail | household | noun | 3.52 | 3.56 | more_than_2 |
Baseline demographic effect (theta ~ age_c * group):
| term | estimate | std.error | statistic | p.value |
|---|---|---|---|---|
| (Intercept) | -0.742 | 0.209 | -3.558 | 0.001 |
| age_c | 0.195 | 0.033 | 5.872 | 0.000 |
| lang_groupmore_than_2 | 0.487 | 0.387 | 1.257 | 0.211 |
| age_c:lang_groupmore_than_2 | 0.034 | 0.075 | 0.453 | 0.651 |
Median b_diff (2_or_fewer b - more_than_2 b): 1.13 (mean 1.05, sd 0.75) | 634/687 items easier for more_than_2
Items with extreme (>2.25 SD) difficulty difference:
| item_definition | item_kind | domain | b_diff | b_diff_z | favors |
|---|---|---|---|---|---|
| nghèo | descriptive_words | other | -2.46 | -4.66 | 2_or_fewer |
| tóe | action_words | verb | -1.78 | -3.75 | 2_or_fewer |
| trống rỗng | descriptive_words | other | -1.78 | -3.75 | 2_or_fewer |
| kinh tởm | descriptive_words | other | -1.61 | -3.53 | 2_or_fewer |
| thời gian | time_words | other | -1.61 | -3.53 | 2_or_fewer |
| sân cát | outdoor | noun | -1.43 | -3.29 | 2_or_fewer |
| cũng thế | quantifiers | other | -1.43 | -3.29 | 2_or_fewer |
| phà | outdoor | noun | -1.43 | -3.29 | 2_or_fewer |
| chúng tôi | pronouns | other | -1.43 | -3.29 | 2_or_fewer |
| một vài | quantifiers | other | -1.43 | -3.29 | 2_or_fewer |
| khoai tây chiên | food_drink | noun | -1.37 | -3.21 | 2_or_fewer |
| giấu | action_words | verb | -1.29 | -3.09 | 2_or_fewer |
| thế và | connecting_words | other | -1.24 | -3.03 | 2_or_fewer |
| ghế bành | furniture_rooms | noun | -1.24 | -3.03 | 2_or_fewer |
| gạt | action_words | verb | -1.09 | -2.84 | 2_or_fewer |
| vợt | toys | noun | -1.09 | -2.84 | 2_or_fewer |
| cách xa | locations | other | -0.97 | -2.68 | 2_or_fewer |
| hạt trang trí | clothing | noun | -0.77 | -2.41 | 2_or_fewer |
| bắt cứ cái gì | quantifiers | other | -0.77 | -2.41 | 2_or_fewer |
| nếm | action_words | verb | -0.74 | -2.37 | 2_or_fewer |
| chú hề | people | other | -0.71 | -2.33 | 2_or_fewer |
| người đưa thư | people | other | -0.71 | -2.33 | 2_or_fewer |
| của họ | pronouns | other | -0.71 | -2.33 | 2_or_fewer |
| ngay cạnh | locations | other | -0.71 | -2.33 | 2_or_fewer |
| bánh quy giòn | food_drink | noun | 3.34 | 3.04 | more_than_2 |
Once the loop above has run, dif_results holds one
item-level DIF table per (language, demographic) combination (keyed
"en_sex_group", "vi_sex_group", etc.). A
natural follow-up, paralleling Part 1’s category-level cross-language
check: are the same categories of items biased for the same
demographic groups in both languages (e.g. are stereotypically-gendered
nouns sex-biased in both English and Vietnamese, as the paper found for
English alone)?
summarize_dif_by_category <- function(dif_tab, language, group_var) {
dif_tab %>%
group_by(item_kind, domain) %>%
summarise(n_items = n(), mean_b_diff = mean(b_diff), pct_extreme = mean(extreme), .groups = "drop") %>%
mutate(language = language, group_var = group_var)
}
dif_by_category <- bind_rows(
summarize_dif_by_category(dif_results[["en_sex_group"]], "English", "sex_group"),
summarize_dif_by_category(dif_results[["vi_sex_group"]], "Vietnamese", "sex_group"),
summarize_dif_by_category(dif_results[["en_edu_group"]], "English", "edu_group"),
summarize_dif_by_category(dif_results[["vi_edu_group"]], "Vietnamese", "edu_group"),
summarize_dif_by_category(dif_results[["en_lang_group"]], "English", "lang_group"),
summarize_dif_by_category(dif_results[["vi_lang_group"]], "Vietnamese", "lang_group")
)
dif_by_category %>%
kable(digits = 2, caption = "Mean item-level DIF and % extreme items by category, language, and demographic.") %>%
html_table_width(c(120, 60, 60, 90, 80, 90, 90))
| item_kind | domain | n_items | mean_b_diff | pct_extreme | language | group_var |
|---|---|---|---|---|---|---|
| action_words | verb | 103 | 0.81 | 0.03 | English | sex_group |
| animals | noun | 43 | 0.70 | 0.00 | English | sex_group |
| body_parts | noun | 26 | 1.05 | 0.04 | English | sex_group |
| clothing | noun | 27 | 0.33 | 0.00 | English | sex_group |
| connecting_words | other | 6 | 0.64 | 0.00 | English | sex_group |
| descriptive_words | other | 63 | 0.75 | 0.02 | English | sex_group |
| food_drink | noun | 68 | 0.60 | 0.03 | English | sex_group |
| furniture_rooms | noun | 34 | 0.74 | 0.03 | English | sex_group |
| games_routines | other | 25 | 1.15 | 0.00 | English | sex_group |
| helping_verbs | other | 20 | 0.41 | 0.05 | English | sex_group |
| household | noun | 50 | 0.64 | 0.02 | English | sex_group |
| locations | other | 26 | 0.16 | 0.12 | English | sex_group |
| outdoor | noun | 31 | 0.57 | 0.03 | English | sex_group |
| people | other | 28 | 0.59 | 0.04 | English | sex_group |
| places | other | 21 | -0.12 | 0.10 | English | sex_group |
| pronouns | other | 25 | -0.03 | 0.24 | English | sex_group |
| quantifiers | other | 17 | 0.61 | 0.00 | English | sex_group |
| question_words | other | 7 | 0.87 | 0.00 | English | sex_group |
| sounds | other | 12 | 0.40 | 0.00 | English | sex_group |
| time_words | other | 12 | -0.45 | 0.08 | English | sex_group |
| toys | noun | 18 | 0.57 | 0.00 | English | sex_group |
| vehicles | noun | 14 | -0.23 | 0.07 | English | sex_group |
| action_words | verb | 103 | -0.71 | 0.01 | Vietnamese | sex_group |
| animals | noun | 46 | -0.51 | 0.04 | Vietnamese | sex_group |
| body_parts | noun | 29 | -0.73 | 0.00 | Vietnamese | sex_group |
| clothing | noun | 28 | -0.66 | 0.11 | Vietnamese | sex_group |
| connecting_words | other | 6 | -0.61 | 0.00 | Vietnamese | sex_group |
| descriptive_words | other | 62 | -0.98 | 0.02 | Vietnamese | sex_group |
| food_drink | noun | 69 | -0.69 | 0.03 | Vietnamese | sex_group |
| furniture_rooms | noun | 33 | -0.70 | 0.03 | Vietnamese | sex_group |
| games_routines | other | 25 | -0.70 | 0.04 | Vietnamese | sex_group |
| helping_verbs | other | 12 | -0.57 | 0.08 | Vietnamese | sex_group |
| household | noun | 58 | -0.77 | 0.02 | Vietnamese | sex_group |
| locations | other | 24 | -0.83 | 0.00 | Vietnamese | sex_group |
| outdoor | noun | 35 | -0.96 | 0.06 | Vietnamese | sex_group |
| people | other | 29 | -0.60 | 0.00 | Vietnamese | sex_group |
| places | other | 23 | -0.91 | 0.04 | Vietnamese | sex_group |
| pronouns | other | 24 | -1.05 | 0.08 | Vietnamese | sex_group |
| quantifiers | other | 16 | -0.70 | 0.00 | Vietnamese | sex_group |
| question_words | other | 7 | -0.55 | 0.00 | Vietnamese | sex_group |
| sounds | other | 12 | -0.62 | 0.00 | Vietnamese | sex_group |
| time_words | other | 12 | -1.37 | 0.08 | Vietnamese | sex_group |
| toys | noun | 19 | -0.52 | 0.00 | Vietnamese | sex_group |
| vehicles | noun | 15 | -1.21 | 0.00 | Vietnamese | sex_group |
| action_words | verb | 103 | 1.27 | 0.03 | English | edu_group |
| animals | noun | 42 | 2.02 | 0.00 | English | edu_group |
| body_parts | noun | 27 | 1.87 | 0.00 | English | edu_group |
| clothing | noun | 26 | 1.77 | 0.00 | English | edu_group |
| connecting_words | other | 6 | 1.21 | 0.00 | English | edu_group |
| descriptive_words | other | 63 | 1.42 | 0.00 | English | edu_group |
| food_drink | noun | 67 | 2.03 | 0.06 | English | edu_group |
| furniture_rooms | noun | 34 | 1.36 | 0.03 | English | edu_group |
| games_routines | other | 25 | 1.61 | 0.00 | English | edu_group |
| helping_verbs | other | 16 | 0.99 | 0.00 | English | edu_group |
| household | noun | 48 | 1.73 | 0.00 | English | edu_group |
| locations | other | 25 | 0.74 | 0.04 | English | edu_group |
| outdoor | noun | 31 | 1.43 | 0.00 | English | edu_group |
| people | other | 27 | 1.14 | 0.07 | English | edu_group |
| places | other | 22 | 0.82 | 0.14 | English | edu_group |
| pronouns | other | 25 | 0.92 | 0.08 | English | edu_group |
| quantifiers | other | 17 | 1.37 | 0.06 | English | edu_group |
| question_words | other | 7 | 1.56 | 0.00 | English | edu_group |
| sounds | other | 12 | 2.20 | 0.00 | English | edu_group |
| time_words | other | 12 | 1.40 | 0.00 | English | edu_group |
| toys | noun | 18 | 1.88 | 0.00 | English | edu_group |
| vehicles | noun | 14 | 1.59 | 0.00 | English | edu_group |
| action_words | verb | 103 | 0.56 | 0.00 | Vietnamese | edu_group |
| animals | noun | 46 | 0.31 | 0.07 | Vietnamese | edu_group |
| body_parts | noun | 29 | 0.83 | 0.00 | Vietnamese | edu_group |
| clothing | noun | 27 | 0.62 | 0.04 | Vietnamese | edu_group |
| connecting_words | other | 6 | -0.02 | 0.00 | Vietnamese | edu_group |
| descriptive_words | other | 62 | 0.48 | 0.03 | Vietnamese | edu_group |
| food_drink | noun | 67 | 0.51 | 0.01 | Vietnamese | edu_group |
| furniture_rooms | noun | 33 | 0.42 | 0.06 | Vietnamese | edu_group |
| games_routines | other | 25 | 0.30 | 0.00 | Vietnamese | edu_group |
| helping_verbs | other | 12 | -0.04 | 0.00 | Vietnamese | edu_group |
| household | noun | 58 | 0.84 | 0.10 | Vietnamese | edu_group |
| locations | other | 24 | 0.53 | 0.00 | Vietnamese | edu_group |
| outdoor | noun | 35 | 0.77 | 0.06 | Vietnamese | edu_group |
| people | other | 29 | 0.20 | 0.00 | Vietnamese | edu_group |
| places | other | 22 | 0.14 | 0.09 | Vietnamese | edu_group |
| pronouns | other | 24 | -0.14 | 0.04 | Vietnamese | edu_group |
| quantifiers | other | 16 | 0.17 | 0.12 | Vietnamese | edu_group |
| question_words | other | 7 | -0.14 | 0.00 | Vietnamese | edu_group |
| sounds | other | 12 | 0.74 | 0.08 | Vietnamese | edu_group |
| time_words | other | 12 | 0.28 | 0.00 | Vietnamese | edu_group |
| toys | noun | 19 | 0.02 | 0.00 | Vietnamese | edu_group |
| vehicles | noun | 15 | 0.78 | 0.07 | Vietnamese | edu_group |
| action_words | verb | 103 | 0.29 | 0.03 | English | lang_group |
| animals | noun | 43 | 0.60 | 0.00 | English | lang_group |
| body_parts | noun | 27 | 0.82 | 0.04 | English | lang_group |
| clothing | noun | 27 | 0.80 | 0.00 | English | lang_group |
| connecting_words | other | 6 | 0.49 | 0.00 | English | lang_group |
| descriptive_words | other | 63 | 0.19 | 0.02 | English | lang_group |
| food_drink | noun | 68 | 0.92 | 0.01 | English | lang_group |
| furniture_rooms | noun | 33 | 0.87 | 0.00 | English | lang_group |
| games_routines | other | 25 | 1.13 | 0.08 | English | lang_group |
| helping_verbs | other | 20 | 0.02 | 0.15 | English | lang_group |
| household | noun | 50 | 0.63 | 0.02 | English | lang_group |
| locations | other | 25 | -0.15 | 0.12 | English | lang_group |
| outdoor | noun | 31 | 0.51 | 0.00 | English | lang_group |
| people | other | 29 | 0.42 | 0.00 | English | lang_group |
| places | other | 22 | 0.42 | 0.05 | English | lang_group |
| pronouns | other | 25 | -0.40 | 0.12 | English | lang_group |
| quantifiers | other | 17 | -0.44 | 0.24 | English | lang_group |
| question_words | other | 7 | 0.61 | 0.00 | English | lang_group |
| sounds | other | 12 | 1.19 | 0.00 | English | lang_group |
| time_words | other | 12 | 0.76 | 0.00 | English | lang_group |
| toys | noun | 18 | 0.55 | 0.00 | English | lang_group |
| vehicles | noun | 14 | 0.52 | 0.00 | English | lang_group |
| action_words | verb | 103 | 0.80 | 0.04 | Vietnamese | lang_group |
| animals | noun | 46 | 1.40 | 0.00 | Vietnamese | lang_group |
| body_parts | noun | 29 | 0.97 | 0.00 | Vietnamese | lang_group |
| clothing | noun | 28 | 1.22 | 0.04 | Vietnamese | lang_group |
| connecting_words | other | 6 | 0.38 | 0.17 | Vietnamese | lang_group |
| descriptive_words | other | 62 | 0.81 | 0.05 | Vietnamese | lang_group |
| food_drink | noun | 69 | 1.41 | 0.03 | Vietnamese | lang_group |
| furniture_rooms | noun | 33 | 1.21 | 0.03 | Vietnamese | lang_group |
| games_routines | other | 25 | 1.27 | 0.00 | Vietnamese | lang_group |
| helping_verbs | other | 12 | 1.03 | 0.00 | Vietnamese | lang_group |
| household | noun | 58 | 1.24 | 0.00 | Vietnamese | lang_group |
| locations | other | 24 | 0.99 | 0.08 | Vietnamese | lang_group |
| outdoor | noun | 35 | 0.96 | 0.06 | Vietnamese | lang_group |
| people | other | 29 | 0.77 | 0.07 | Vietnamese | lang_group |
| places | other | 23 | 1.12 | 0.00 | Vietnamese | lang_group |
| pronouns | other | 24 | 0.53 | 0.08 | Vietnamese | lang_group |
| quantifiers | other | 16 | 0.75 | 0.19 | Vietnamese | lang_group |
| question_words | other | 7 | 1.29 | 0.00 | Vietnamese | lang_group |
| sounds | other | 12 | 1.39 | 0.00 | Vietnamese | lang_group |
| time_words | other | 12 | 1.02 | 0.08 | Vietnamese | lang_group |
| toys | noun | 19 | 0.94 | 0.05 | Vietnamese | lang_group |
| vehicles | noun | 15 | 1.24 | 0.00 | Vietnamese | lang_group |
Same visual logic as Part 1’s category-level EN-VI comparison
(part1-category-crosslang-plot): each point is one
item_kind, with its mean b_diff (favors
group1 when negative, group2 when positive –
see dif_specs above for which demographic level is
group1/group2 in each panel) plotted for
English (x) against Vietnamese (y). Categories in the upper-right or
lower-left quadrants are biased toward the same group in both
languages; categories in the upper-left/lower-right are biased toward
opposite groups depending on language.
dif_by_category_wide <- dif_by_category %>%
select(group_var, item_kind, domain, language, mean_b_diff) %>%
pivot_wider(names_from = language, values_from = mean_b_diff) %>%
filter(!is.na(English), !is.na(Vietnamese))
ggplot(dif_by_category_wide, aes(x = English, y = Vietnamese, color = domain, label = item_kind)) +
geom_hline(yintercept = 0, linetype = "dashed", color = "grey70") +
geom_vline(xintercept = 0, linetype = "dashed", color = "grey70") +
geom_point() +
ggrepel::geom_text_repel(size = 2.8, show.legend = FALSE, max.overlaps = 15) +
facet_wrap(~group_var, nrow = 1) +
theme_classic() +
xlab("English: mean b_diff (group1 - group2)") +
ylab("Vietnamese: mean b_diff (group1 - group2)")
Results: Most words show the same demographic
fit_group_irt() with SE = TRUE and draw
imputations from each item’s parameter covariance matrix (see Stenhaug,
Frank, & Domingue 2021, cited in Kachergis et al. 2022) to
distinguish genuine DIF clusters from noise, rather than relying on
point estimates alone.fit_group_irt()/compute_dif()/prune_and_refit()
machinery generalizes to any binary (or map-able
multi-level) grouping variable added to d_demo.b_diff.Are item difficulties consistent in English and Vietnamese – i.e. do
words that are hard for English-Vietnamese bilingual children to produce
in English also tend to be hard for them to produce in Vietnamese? This
extracts each item’s IRT difficulty (b) from the same
mod_2pl_en/mod_2pl_vi models used in Part 1,
z-scores it within each language (each item’s b relative to
that language’s own full item-set mean/SD – the same within-language
standardization used for category_wide in Part 1 and for
ability_noun in 03_bifactor_noun_verb.Rmd, for
the same reason: the two models are independently calibrated, so raw
b isn’t on a shared scale), and correlates the standardized
difficulties (Pearson) for translation-equivalent item pairs (e.g. “dog”
/ “con chó”) – unlike Parts 1-2, this needs an item-level (not just
category-level) EN-VI mapping.
Matching items across languages needs an item-level EN-VI translation
crosswalk (which English item_definition corresponds to
which Vietnamese item_definition). The two fields files
share the same item_kind categories (used in Parts 1-2
above) but list words in each language’s own order (Vietnamese items
appear alphabetized by the Vietnamese word), so there’s no positional
correspondence to exploit – the mapping has to come from the original
CDI adaptation/translation documentation, or be built by hand.
The chunk below looks for data/en_vi_item_crosswalk.csv
(columns: item_kind, en_definition,
vi_definition). If it isn’t there, it writes out a
template (one row per English word-production item,
vi_definition left blank) for someone to fill in by hand or
from the adaptation documentation, and the rest of this Part is skipped
until that file exists.
crosswalk_path <- "data/en_vi_item_crosswalk.csv"
if (!file.exists(crosswalk_path)) {
template <- en_data$d_items %>%
filter(item_kind %in% intersect(en_data$d_items$item_kind, vi_data$d_items$item_kind)) %>%
transmute(item_kind, en_definition = item_definition, vi_definition = NA_character_)
write_csv(template, crosswalk_path)
warning(
"No translation crosswalk found. Wrote a template with ", nrow(template),
" English items (vi_definition left blank) to '", crosswalk_path, "' -- ",
"fill in the Vietnamese translation equivalent for each row (or source it from the ",
"CDI adaptation documentation) before re-running this analysis."
)
}
crosswalk_ready <- file.exists(crosswalk_path) && !all(is.na(read_csv(crosswalk_path, show_col_types = FALSE)$vi_definition))
The remaining chunks in this Part are set to only evaluate once
data/en_vi_item_crosswalk.csv exists and is filled in
(eval=crosswalk_ready); until then they’re a no-op.
crosswalk <- read_csv(crosswalk_path, show_col_types = FALSE) %>% filter(!is.na(vi_definition))
# z-scored against each language's FULL item set (diff_en/diff_vi, all
# ~680/~690 word-production items), not just the matched subset below --
# same convention as category_wide in Part 1 and cross_lang_join in
# 03_bifactor_noun_verb.Rmd, so a matched pair's z-scores reflect where
# each word sits in its own language's overall difficulty distribution.
en_mean_b <- mean(diff_en$b); en_sd_b <- sd(diff_en$b)
vi_mean_b <- mean(diff_vi$b); vi_sd_b <- sd(diff_vi$b)
matched <- crosswalk %>%
inner_join(diff_en %>% rename(en_b = b, en_item_kind = item_kind) %>% select(-difficulty_rank),
by = c("en_definition" = "item_definition")) %>%
inner_join(diff_vi %>% rename(vi_b = b, vi_item_kind = item_kind) %>% select(-difficulty_rank),
by = c("vi_definition" = "item_definition")) %>%
mutate(
en_b_z = (en_b - en_mean_b) / en_sd_b,
vi_b_z = (vi_b - vi_mean_b) / vi_sd_b
)
## Warning in inner_join(., diff_en %>% rename(en_b = b, en_item_kind = item_kind) %>% : Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 11 of `x` matches multiple rows in `y`.
## ℹ Row 262 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
## "many-to-many"` to silence this warning.
## Warning in inner_join(., diff_vi %>% rename(vi_b = b, vi_item_kind = item_kind) %>% : Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 36 of `x` matches multiple rows in `y`.
## ℹ Row 324 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
## "many-to-many"` to silence this warning.
cat(nrow(matched), "translation-equivalent item pairs matched",
"(out of", nrow(diff_en), "English and", nrow(diff_vi), "Vietnamese word-production items).")
## 728 translation-equivalent item pairs matched (out of 681 English and 687 Vietnamese word-production items).
cor.test(matched$en_b_z, matched$vi_b_z, method = "pearson")
##
## Pearson's product-moment correlation
##
## data: matched$en_b_z and matched$vi_b_z
## t = 19.912, df = 726, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
## 0.5452106 0.6393792
## sample estimates:
## cor
## 0.5943283
ggplot(matched, aes(x = en_b_z, y = vi_b_z)) +
geom_point(alpha = 0.6) +
geom_smooth(method = "lm", se = TRUE) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey70") +
theme_classic() +
xlab("English difficulty (z-scored within language)") +
ylab("Vietnamese difficulty (z-scored within language)")
## `geom_smooth()` using formula = 'y ~ x'
matched <- matched %>%
mutate(domain = case_when(
en_item_kind %in% verb_kinds ~ "verb",
en_item_kind %in% noun_kinds ~ "noun",
TRUE ~ "other"
))
matched %>%
group_by(domain) %>%
summarise(
n_items = n(),
pearson_r = cor(en_b_z, vi_b_z, method = "pearson"),
.groups = "drop"
) %>%
kable(digits = 2, caption = "Item-difficulty correlation (standardized b) between English and Vietnamese, by domain.") %>%
html_table_width(c(100, 90, 100))
| domain | n_items | pearson_r |
|---|---|---|
| noun | 342 | 0.64 |
| other | 270 | 0.52 |
| verb | 116 | 0.54 |
ggplot(matched, aes(x = en_b_z, y = vi_b_z, color = domain)) +
geom_point(alpha = 0.6) +
geom_smooth(method = "lm", se = FALSE) +
facet_wrap(~domain) +
theme_classic() +
theme(legend.position = "none") +
xlab("English difficulty (z-scored within language)") +
ylab("Vietnamese difficulty (z-scored within language)")
## `geom_smooth()` using formula = 'y ~ x'
matched by en_definition, then tested as a
moderator of the correlation
(e.g. lm(vi_b_z ~ en_b_z * concreteness, data = matched)).