This report documents the reproducible FY 2025-26 analysis of the Parent and Child Together (PACT) Parenting Ladder and Program Improvement surveys. It covers survey participation, parenting outcomes, family activities, participant characteristics, and program experience. No focus-group component was conducted this year.
The primary Parenting Ladder analysis uses distinct survey episodes. Rows that repeat the same participant, survey date, and response pattern because of multiple service records are collapsed. Genuinely different survey episodes are retained. A sensitivity analysis also limits the data to the most recent episode for each participant.
pre_raw <- read_excel(pre_file, sheet = "Pre + Demographics - Rows") |>
clean_names()
post_raw <- read_excel(post_file, sheet = "Post + Demographics - Rows") |>
clean_names()
pi_raw <- read_excel(pi_file, sheet = "PACT Program Improvement Survey") |>
clean_names()
expected_pl_rows <- 344L
expected_pl_items <- 16L
expected_pi_items <- 16L
stopifnot(
nrow(pre_raw) == nrow(post_raw),
ncol(pre_raw) >= 44,
ncol(post_raw) >= 46,
ncol(pi_raw) >= 22
)
if (!identical(as.character(pre_raw[[1]]), as.character(post_raw[[1]]))) {
stop(
"The pre and post files are not aligned row-for-row by Participant ID. ",
"Review the source export before continuing.",
call. = FALSE
)
}
if (
sum(str_detect(names(pre_raw)[26:41], "^x?\\d+_")) != expected_pl_items ||
sum(str_detect(names(post_raw)[26:41], "^x?\\d+_")) != expected_pl_items ||
sum(str_detect(names(pi_raw)[4:19], "^x?\\d+_")) != expected_pi_items
) {
stop(
"The expected 16-item survey blocks were not found in their prior locations. ",
"Review the workbook structure before continuing.",
call. = FALSE
)
}
pl_dictionary <- tibble(
item_number = 1:16,
item_label = c(
"Knowledge of how my child is growing and developing",
"Knowledge of what behavior is typical at this age",
"Confidence in myself as a parent",
"Confidence I can help my child learn",
"Confidence in setting limits for my child",
"Ability to keep my child safe and healthy",
"Ability to recognize when my child is upset",
"Find positive ways to guide and discipline my child",
"Listen to my child to understand their feelings",
"Know fun activities to help my child learn",
"Play with my child frequently",
"Number of other families I can depend on for support",
"Strength of my connections to other families",
"Ability to get services that I need for my child",
"Ability to get services that I need for myself",
"Ability to manage the day-to-day stress of being a parent"
),
factor = c(
rep("Parental Knowledge, Confidence, and Abilities", 11),
rep("Social Support", 2),
rep("Ability to Get Services", 2),
"Additional Parenting Stress Item"
)
)
activity_dictionary <- tibble(
activity = c("Read or look at picture books", "Talk or share stories", "Sing"),
activity_order = 1:3
)
pi_dictionary <- tibble(
item_number = 1:16,
item_label = c(
"Services and activities are offered at a convenient time and location",
"Staff are welcoming and respectful",
"Staff ask about my family's concerns",
"Staff follow up about my family's concerns",
"Staff respect my culture and traditions",
"Staff communicate with me in my home language",
"Staff respect my identity",
"The program offers opportunities to learn about diversity, equity, and inclusion",
"Staff value my feedback and ideas",
"I spend more time playing and interacting with my child",
"I have opportunities to build strong relationships with other families",
"I have other families I can depend on for support",
"I am able to get the services my family needs",
"We have more consistent routines at home",
"I am better able to understand and respond to my child's cues",
"I am better able to understand and support my child's behavior"
),
domain = c(rep("Services and Staff", 9), rep("Parent and Family Outcomes", 7))
)
The source exports contain one row per associated service. Consequently, the same survey can appear on multiple rows. The code below builds a paired record, then collapses rows only when the participant ID, survey-entry date, and all outcome responses are identical.
to_numeric <- function(x) suppressWarnings(as.numeric(as.character(x)))
parse_survey_date <- function(x) {
if (inherits(x, "Date")) return(x)
if (inherits(x, c("POSIXct", "POSIXt"))) return(as.Date(x))
parsed <- suppressWarnings(lubridate::parse_date_time(
as.character(x),
orders = c("mdy", "ymd", "mdy HMS", "ymd HMS")
))
as.Date(parsed)
}
pre_items <- pre_raw[, 26:41] |>
mutate(across(everything(), to_numeric))
names(pre_items) <- sprintf("pl_%02d_pre", 1:16)
post_items <- post_raw[, 26:41] |>
mutate(across(everything(), to_numeric))
names(post_items) <- sprintf("pl_%02d_post", 1:16)
pre_activities <- pre_raw[, 42:44] |>
mutate(across(everything(), to_numeric))
names(pre_activities) <- c("read_pre", "talk_pre", "sing_pre")
# The post export contains embedded Before PACT copies for Read and Talk.
# Columns 43, 45, and 46 contain the intended NOW values.
post_activities <- post_raw[, c(43, 45, 46)] |>
mutate(across(everything(), to_numeric))
names(post_activities) <- c("read_post", "talk_post", "sing_post")
pl_paired_rows <- bind_cols(
pre_raw |>
transmute(
participant_id = as.character(participant_id),
survey_date = parse_survey_date(date_pl_survey_entered),
service_date = parse_survey_date(date_of_service),
enrollment = as.character(enrollment_associated_with_pl_survey),
gender = as.character(gender_genero),
ethnicity = as.character(ethnicity_cual_es_su_etnicidad),
primary_language = as.character(primary_language_cual_es_el_primer_idioma),
client_type = as.character(client_type),
employment = as.character(what_is_your_current_employment_status_cual_es_su_situacion_laboral),
income = as.character(what_was_your_family_income_in_the_last_12_months),
education = as.character(what_is_your_highest_level_of_education),
survey_language = as.character(language_of_survey)
),
pre_items,
post_items,
pre_activities,
post_activities
)
response_columns <- c(
names(pre_items), names(post_items),
names(pre_activities), names(post_activities)
)
pl_episodes <- pl_paired_rows |>
arrange(participant_id, survey_date, service_date) |>
distinct(
participant_id,
survey_date,
across(all_of(response_columns)),
.keep_all = TRUE
) |>
group_by(participant_id) |>
mutate(episode_number = row_number()) |>
ungroup() |>
mutate(episode_id = paste(participant_id, episode_number, sep = "_"))
pl_latest_participant <- pl_episodes |>
arrange(participant_id, desc(survey_date), desc(service_date)) |>
distinct(participant_id, .keep_all = TRUE)
stopifnot(
all(map_lgl(pl_episodes[response_columns], ~ all(is.na(.x) | .x %in% 0:7))),
n_distinct(pl_episodes$participant_id) == n_distinct(pre_raw$participant_id)
)
pi_items <- pi_raw[, 4:19]
names(pi_items) <- sprintf("pi_%02d", 1:16)
pi_activities <- pi_raw[, 20:22] |>
mutate(across(everything(), to_numeric))
names(pi_activities) <- c("read", "talk", "sing")
pi <- bind_cols(
pi_raw |>
transmute(
participant_id = as.character(participant_record_id),
survey_date = parse_survey_date(date_survey_completed),
survey_language = as.character(language_of_survey)
),
pi_items,
pi_activities
) |>
distinct()
likert_levels <- c("Strongly Disagree", "Disagree", "Agree", "Strongly Agree")
pi <- pi |>
mutate(
across(starts_with("pi_"), ~ factor(.x, levels = likert_levels)),
across(c(read, talk, sing), to_numeric)
)
stopifnot(
length(grep("^pi_\\d{2}$", names(pi))) == expected_pi_items,
all(map_lgl(pi[c("read", "talk", "sing")], ~ all(is.na(.x) | .x %in% 0:7)))
)
qa_summary <- tibble(
measure = c(
"Parenting Ladder pre source rows",
"Parenting Ladder post source rows",
"Distinct Parenting Ladder participants",
"Distinct Parenting Ladder survey episodes",
"Participants with more than one distinct episode",
"Program Improvement source rows",
"Distinct Program Improvement submissions after exact deduplication",
"Distinct Program Improvement participants",
"Program Improvement participants with multiple submissions"
),
value = c(
nrow(pre_raw),
nrow(post_raw),
n_distinct(pl_episodes$participant_id),
nrow(pl_episodes),
pl_episodes |> count(participant_id) |> filter(n > 1) |> nrow(),
nrow(pi_raw),
nrow(pi),
n_distinct(pi$participant_id),
pi |> count(participant_id) |> filter(n > 1) |> nrow()
)
)
kable(qa_summary, col.names = c("Data-quality measure", "Count"))
| Data-quality measure | Count |
|---|---|
| Parenting Ladder pre source rows | 344 |
| Parenting Ladder post source rows | 344 |
| Distinct Parenting Ladder participants | 225 |
| Distinct Parenting Ladder survey episodes | 241 |
| Participants with more than one distinct episode | 16 |
| Program Improvement source rows | 140 |
| Distinct Program Improvement submissions after exact deduplication | 140 |
| Distinct Program Improvement participants | 132 |
| Program Improvement participants with multiple submissions | 8 |
paired_item_result <- function(data, item_number) {
pre_name <- sprintf("pl_%02d_pre", item_number)
post_name <- sprintf("pl_%02d_post", item_number)
pairs <- data |>
transmute(pre = .data[[pre_name]], post = .data[[post_name]]) |>
drop_na()
test <- t.test(pairs$post, pairs$pre, paired = TRUE)
difference <- pairs$post - pairs$pre
tibble(
item_number = item_number,
matched_n = nrow(pairs),
mean_pre = mean(pairs$pre),
mean_post = mean(pairs$post),
mean_change = mean(difference),
pct_6_7_pre = mean(pairs$pre %in% 6:7) * 100,
pct_6_7_post = mean(pairs$post %in% 6:7) * 100,
t_statistic = unname(test$statistic),
df = unname(test$parameter),
p_value = test$p.value,
cohen_dz = mean(difference) / sd(difference)
)
}
pl_item_results <- map_dfr(1:16, ~ paired_item_result(pl_episodes, .x)) |>
left_join(pl_dictionary, by = "item_number") |>
mutate(
p_adjusted_bh = p.adjust(p_value, method = "BH"),
significant = p_adjusted_bh < .05
)
kable(
pl_item_results |>
transmute(
Item = item_label,
N = matched_n,
Before = round(mean_pre, 1),
After = round(mean_post, 1),
Change = round(mean_change, 1),
`Before 6 or 7` = scales::percent(pct_6_7_pre / 100, accuracy = 1),
`After 6 or 7` = scales::percent(pct_6_7_post / 100, accuracy = 1),
`Adjusted p` = format.pval(p_adjusted_bh, digits = 3, eps = .001),
`Effect size` = round(cohen_dz, 2)
),
align = c("l", rep("r", 8))
)
| Item | N | Before | After | Change | Before 6 or 7 | After 6 or 7 | Adjusted p | Effect size |
|---|---|---|---|---|---|---|---|---|
| Knowledge of how my child is growing and developing | 237 | 4.7 | 6.4 | 1.7 | 25% | 91% | <0.001 | 1.54 |
| Knowledge of what behavior is typical at this age | 236 | 4.7 | 6.4 | 1.8 | 25% | 91% | <0.001 | 1.43 |
| Confidence in myself as a parent | 236 | 4.9 | 6.4 | 1.5 | 36% | 90% | <0.001 | 1.23 |
| Confidence I can help my child learn | 235 | 5.1 | 6.7 | 1.5 | 42% | 95% | <0.001 | 1.22 |
| Confidence in setting limits for my child | 236 | 4.8 | 6.2 | 1.4 | 32% | 80% | <0.001 | 1.11 |
| Ability to keep my child safe and healthy | 236 | 6.2 | 6.8 | 0.6 | 78% | 97% | <0.001 | 0.60 |
| Ability to recognize when my child is upset | 235 | 5.9 | 6.7 | 0.9 | 66% | 97% | <0.001 | 0.71 |
| Find positive ways to guide and discipline my child | 233 | 5.0 | 6.3 | 1.4 | 33% | 85% | <0.001 | 1.12 |
| Listen to my child to understand their feelings | 237 | 5.2 | 6.5 | 1.3 | 46% | 91% | <0.001 | 1.01 |
| Know fun activities to help my child learn | 236 | 4.5 | 6.6 | 2.1 | 21% | 94% | <0.001 | 1.45 |
| Play with my child frequently | 236 | 5.4 | 6.7 | 1.3 | 52% | 94% | <0.001 | 0.96 |
| Number of other families I can depend on for support | 228 | 4.1 | 5.5 | 1.4 | 21% | 63% | <0.001 | 1.07 |
| Strength of my connections to other families | 233 | 4.1 | 5.7 | 1.6 | 20% | 64% | <0.001 | 1.15 |
| Ability to get services that I need for my child | 235 | 4.8 | 6.4 | 1.6 | 35% | 87% | <0.001 | 1.05 |
| Ability to get services that I need for myself | 233 | 4.8 | 6.2 | 1.3 | 35% | 76% | <0.001 | 0.95 |
| Ability to manage the day-to-day stress of being a parent | 235 | 4.5 | 6.1 | 1.6 | 27% | 78% | <0.001 | 1.21 |
pl_item_plot <- pl_item_results |>
mutate(item_label = fct_reorder(item_label, mean_change)) |>
ggplot(aes(y = item_label)) +
geom_segment(
aes(x = mean_pre, xend = mean_post, yend = item_label),
colour = "#B8B8B8", linewidth = 1
) +
geom_point(aes(x = mean_pre, colour = "Before PACT"), size = 3) +
geom_point(aes(x = mean_post, colour = "After PACT"), size = 3) +
scale_colour_manual(
values = c("Before PACT" = colors$before, "After PACT" = colors$after),
breaks = c("Before PACT", "After PACT")
) +
scale_x_continuous(limits = c(1, 7), breaks = 1:7) +
labs(
title = "Parenting Ladder ratings increased after PACT",
subtitle = "Mean ratings among distinct matched survey episodes",
x = "Average rating (1 to 7)", y = NULL, colour = NULL
)
pl_item_plot
The three established factors use items 1–11, 12–13, and 14–15. Item 16 is reported separately and is not included in a factor score.
factor_map <- list(
"Parental Knowledge, Confidence, and Abilities" = 1:11,
"Social Support" = 12:13,
"Ability to Get Services" = 14:15
)
score_factor <- function(data, indices, wave) {
selected <- sprintf("pl_%02d_%s", indices, wave)
values <- as.data.frame(data[selected])
score <- rowMeans(values, na.rm = TRUE)
score[rowSums(!is.na(values)) == 0] <- NA_real_
score
}
pl_factor_scores <- pl_episodes |>
select(episode_id, participant_id)
for (factor_name in names(factor_map)) {
safe_name <- factor_name |> janitor::make_clean_names()
pl_factor_scores[[paste0(safe_name, "_pre")]] <-
score_factor(pl_episodes, factor_map[[factor_name]], "pre")
pl_factor_scores[[paste0(safe_name, "_post")]] <-
score_factor(pl_episodes, factor_map[[factor_name]], "post")
}
factor_result <- function(factor_name) {
safe_name <- janitor::make_clean_names(factor_name)
pairs <- pl_factor_scores |>
transmute(
pre = .data[[paste0(safe_name, "_pre")]],
post = .data[[paste0(safe_name, "_post")]]
) |>
drop_na()
test <- t.test(pairs$post, pairs$pre, paired = TRUE)
difference <- pairs$post - pairs$pre
tibble(
factor = factor_name,
matched_n = nrow(pairs),
mean_pre = mean(pairs$pre),
mean_post = mean(pairs$post),
mean_change = mean(difference),
p_value = test$p.value,
cohen_dz = mean(difference) / sd(difference)
)
}
pl_factor_results <- map_dfr(names(factor_map), factor_result)
reliability_results <- map_dfr(names(factor_map), function(factor_name) {
indices <- factor_map[[factor_name]]
pre_alpha <- psych::alpha(
pl_episodes[sprintf("pl_%02d_pre", indices)],
warnings = FALSE, check.keys = FALSE
)$total$raw_alpha
post_alpha <- psych::alpha(
pl_episodes[sprintf("pl_%02d_post", indices)],
warnings = FALSE, check.keys = FALSE
)$total$raw_alpha
tibble(factor = factor_name, alpha_pre = pre_alpha, alpha_post = post_alpha)
})
kable(
pl_factor_results |>
left_join(reliability_results, by = "factor") |>
transmute(
Factor = factor,
N = matched_n,
Before = round(mean_pre, 1),
After = round(mean_post, 1),
Change = round(mean_change, 1),
`p value` = format.pval(p_value, digits = 3, eps = .001),
`Effect size` = round(cohen_dz, 2),
`Alpha before` = round(alpha_pre, 2),
`Alpha after` = round(alpha_post, 2)
)
)
| Factor | N | Before | After | Change | p value | Effect size | Alpha before | Alpha after |
|---|---|---|---|---|---|---|---|---|
| Parental Knowledge, Confidence, and Abilities | 237 | 5.1 | 6.5 | 1.4 | <0.001 | 1.45 | 0.93 | 0.87 |
| Social Support | 236 | 4.1 | 5.6 | 1.5 | <0.001 | 1.19 | 0.88 | 0.87 |
| Ability to Get Services | 236 | 4.8 | 6.3 | 1.5 | <0.001 | 1.06 | 0.90 | 0.78 |
pl_factor_plot_data <- pl_factor_results |>
select(factor, mean_pre, mean_post) |>
pivot_longer(c(mean_pre, mean_post), names_to = "wave", values_to = "mean") |>
mutate(
wave = recode(wave, mean_pre = "Before PACT", mean_post = "After PACT"),
# Reverse the dodge grouping so Before appears above After in the
# horizontal chart; the legend order remains Before then After below.
wave = factor(wave, levels = c("After PACT", "Before PACT")),
factor = forcats::fct_rev(base::factor(factor, levels = names(factor_map)))
)
pl_factor_plot <- ggplot(pl_factor_plot_data, aes(x = mean, y = factor, fill = wave)) +
geom_col(position = position_dodge(width = .75), width = .68) +
geom_text(
aes(label = number(mean, accuracy = .1)),
position = position_dodge(width = .75), hjust = -.2, size = 3.6
) +
scale_fill_manual(
values = c("Before PACT" = colors$before, "After PACT" = colors$after),
breaks = c("Before PACT", "After PACT")
) +
scale_x_continuous(limits = c(0, 7.4), breaks = 0:7) +
labs(
title = "Average ratings on the Parenting Ladder factors",
x = "Average rating (1 to 7)", y = NULL, fill = NULL
)
pl_factor_plot
This companion view shows the average percentage of parents selecting 6 or 7 across the items within each Parenting Ladder factor. Percentages are based on the matched responses available for each item.
significance_stars <- function(p) {
case_when(
p < .001 ~ "***",
p < .01 ~ "**",
p < .05 ~ "*",
TRUE ~ ""
)
}
pl_factor_top_two <- imap_dfr(factor_map, function(indices, factor_name) {
item_results <- pl_item_results |>
filter(item_number %in% indices)
tibble(
factor = factor_name,
item_count = nrow(item_results),
pct_6_7_pre = mean(item_results$pct_6_7_pre, na.rm = TRUE),
pct_6_7_post = mean(item_results$pct_6_7_post, na.rm = TRUE)
)
}) |>
left_join(
pl_factor_results |> select(factor, matched_n, p_value),
by = "factor"
) |>
mutate(significance = significance_stars(p_value))
kable(
pl_factor_top_two |>
transmute(
Factor = factor,
`Items in factor` = item_count,
`Matched N` = matched_n,
`Before 6 or 7` = percent(pct_6_7_pre / 100, accuracy = 1),
`After 6 or 7` = percent(pct_6_7_post / 100, accuracy = 1),
`p value` = format.pval(p_value, digits = 3, eps = .001)
)
)
| Factor | Items in factor | Matched N | Before 6 or 7 | After 6 or 7 | p value |
|---|---|---|---|---|---|
| Parental Knowledge, Confidence, and Abilities | 11 | 237 | 41% | 91% | <0.001 |
| Social Support | 2 | 236 | 21% | 63% | <0.001 |
| Ability to Get Services | 2 | 236 | 35% | 82% | <0.001 |
factor_label_levels <- pl_factor_top_two |>
transmute(factor_label = paste0(factor, significance)) |>
pull(factor_label)
pl_factor_top_two_plot_data <- pl_factor_top_two |>
select(factor, significance, pct_6_7_pre, pct_6_7_post) |>
pivot_longer(
c(pct_6_7_pre, pct_6_7_post),
names_to = "wave",
values_to = "percent_top_two"
) |>
mutate(
wave = recode(
wave,
pct_6_7_pre = "Before PACT",
pct_6_7_post = "After PACT"
),
# Reverse dodge grouping so Before appears above After while retaining
# Before-then-After legend order.
wave = factor(wave, levels = c("After PACT", "Before PACT")),
factor_label = paste0(factor, significance),
factor_label = forcats::fct_rev(
base::factor(
factor_label,
levels = factor_label_levels
)
)
)
pl_factor_top_two_plot <- ggplot(
pl_factor_top_two_plot_data,
aes(x = percent_top_two, y = factor_label, fill = wave)
) +
geom_col(position = position_dodge(width = .75), width = .68) +
geom_text(
aes(label = percent(percent_top_two / 100, accuracy = 1)),
position = position_dodge(width = .75), hjust = -.15, size = 3.6
) +
scale_fill_manual(
values = c("Before PACT" = colors$before, "After PACT" = colors$after),
breaks = c("Before PACT", "After PACT")
) +
scale_x_continuous(
limits = c(0, 105), breaks = seq(0, 100, 20),
labels = ~ percent(.x / 100, accuracy = 1)
) +
labs(
title = "Parents selecting the top two Parenting Ladder ratings",
subtitle = "Average percent selecting 6 or 7 across items in each factor",
x = "Percent selecting 6 or 7", y = NULL, fill = NULL,
caption = "* p < .05; ** p < .01; *** p < .001 for paired factor-score comparisons."
)
pl_factor_top_two_plot
activity_result <- function(data, activity) {
pre_name <- paste0(tolower(activity), "_pre")
post_name <- paste0(tolower(activity), "_post")
pairs <- data |>
transmute(pre = .data[[pre_name]], post = .data[[post_name]]) |>
drop_na()
test <- t.test(pairs$post, pairs$pre, paired = TRUE)
difference <- pairs$post - pairs$pre
tibble(
activity = str_to_title(activity),
matched_n = nrow(pairs),
mean_pre = mean(pairs$pre),
mean_post = mean(pairs$post),
mean_change = mean(difference),
pct_6_7_pre = mean(pairs$pre %in% 6:7) * 100,
pct_6_7_post = mean(pairs$post %in% 6:7) * 100,
p_value = test$p.value,
cohen_dz = mean(difference) / sd(difference)
)
}
activity_results <- map_dfr(c("read", "talk", "sing"), ~ activity_result(pl_episodes, .x))
kable(
activity_results |>
transmute(
Activity = activity,
N = matched_n,
Before = round(mean_pre, 1),
After = round(mean_post, 1),
Change = round(mean_change, 1),
`Before 6 or 7` = scales::percent(pct_6_7_pre / 100, accuracy = 1),
`After 6 or 7` = scales::percent(pct_6_7_post / 100, accuracy = 1),
`p value` = format.pval(p_value, digits = 3, eps = .001),
`Effect size` = round(cohen_dz, 2)
)
)
| Activity | N | Before | After | Change | Before 6 or 7 | After 6 or 7 | p value | Effect size |
|---|---|---|---|---|---|---|---|---|
| Read | 233 | 4.6 | 6.1 | 1.5 | 33% | 71% | <0.001 | 0.96 |
| Talk | 233 | 5.4 | 6.5 | 1.1 | 54% | 84% | <0.001 | 0.80 |
| Sing | 232 | 5.3 | 6.6 | 1.3 | 51% | 91% | <0.001 | 0.87 |
activity_plot_data <- activity_results |>
select(activity, mean_pre, mean_post) |>
pivot_longer(c(mean_pre, mean_post), names_to = "wave", values_to = "mean") |>
mutate(
wave = recode(wave, mean_pre = "Before PACT", mean_post = "After PACT"),
wave = factor(wave, levels = c("Before PACT", "After PACT"))
)
activity_plot <- ggplot(activity_plot_data, aes(x = activity, y = mean, fill = wave)) +
geom_col(position = position_dodge(width = .75), width = .68) +
geom_text(
aes(label = number(mean, accuracy = .1)),
position = position_dodge(width = .75), vjust = -.4
) +
scale_fill_manual(
values = c("Before PACT" = colors$before, "After PACT" = colors$after),
breaks = c("Before PACT", "After PACT")
) +
scale_y_continuous(limits = c(0, 7.5), breaks = 0:7) +
labs(
title = "Frequency of weekly activities with children",
subtitle = "Average number of days per week",
x = NULL, y = "Average days per week", fill = NULL
)
activity_plot
The following comparison checks whether retaining multiple distinct episodes for a small number of participants materially changes the findings.
episode_sensitivity <- map_dfr(1:16, ~ paired_item_result(pl_episodes, .x)) |>
select(item_number, episode_change = mean_change)
participant_sensitivity <- map_dfr(1:16, ~ paired_item_result(pl_latest_participant, .x)) |>
select(item_number, participant_change = mean_change)
sensitivity_results <- episode_sensitivity |>
left_join(participant_sensitivity, by = "item_number") |>
left_join(pl_dictionary, by = "item_number") |>
mutate(absolute_difference = abs(episode_change - participant_change))
kable(
sensitivity_results |>
transmute(
Item = item_label,
`Episode-level change` = round(episode_change, 2),
`One-participant change` = round(participant_change, 2),
`Absolute difference` = round(absolute_difference, 2)
)
)
| Item | Episode-level change | One-participant change | Absolute difference |
|---|---|---|---|
| Knowledge of how my child is growing and developing | 1.69 | 1.71 | 0.02 |
| Knowledge of what behavior is typical at this age | 1.78 | 1.79 | 0.01 |
| Confidence in myself as a parent | 1.52 | 1.52 | 0.00 |
| Confidence I can help my child learn | 1.51 | 1.52 | 0.00 |
| Confidence in setting limits for my child | 1.39 | 1.40 | 0.01 |
| Ability to keep my child safe and healthy | 0.60 | 0.61 | 0.01 |
| Ability to recognize when my child is upset | 0.85 | 0.87 | 0.02 |
| Find positive ways to guide and discipline my child | 1.35 | 1.38 | 0.03 |
| Listen to my child to understand their feelings | 1.27 | 1.31 | 0.03 |
| Know fun activities to help my child learn | 2.10 | 2.14 | 0.04 |
| Play with my child frequently | 1.31 | 1.33 | 0.02 |
| Number of other families I can depend on for support | 1.37 | 1.34 | 0.03 |
| Strength of my connections to other families | 1.56 | 1.53 | 0.03 |
| Ability to get services that I need for my child | 1.62 | 1.58 | 0.04 |
| Ability to get services that I need for myself | 1.34 | 1.32 | 0.03 |
| Ability to manage the day-to-day stress of being a parent | 1.58 | 1.57 | 0.01 |
Demographic summaries use one record per participant to avoid counting participants with multiple survey episodes more than once.
collapse_income <- function(x) {
x <- str_squish(as.character(x))
case_when(
is.na(x) | x == "" ~ NA_character_,
str_detect(x, regex("prefer|no answer|decline", ignore_case = TRUE)) ~ NA_character_,
str_detect(x, regex("more than.*100,?000|^\\$?(50|75),?000", ignore_case = TRUE)) ~ "$50,000 or more",
str_detect(x, regex("less than.*10,?000|^\\$?(10|20|30|40),?000", ignore_case = TRUE)) ~ "Under $50,000",
TRUE ~ NA_character_
)
}
collapse_education <- function(x) {
x <- str_squish(as.character(x))
case_when(
is.na(x) | x == "" ~ NA_character_,
str_detect(x, regex("prefer|no answer|decline", ignore_case = TRUE)) ~ NA_character_,
str_detect(x, regex("less.*high school", ignore_case = TRUE)) ~ "Less than high school",
str_detect(x, regex("high school|GED", ignore_case = TRUE)) ~ "High school diploma or GED",
str_detect(x, regex("some college|associate", ignore_case = TRUE)) ~ "Some college or associate degree",
str_detect(x, regex("bachelor|graduate|professional|master|doctor|PhD", ignore_case = TRUE)) ~ "Bachelor's degree or higher",
TRUE ~ "Other"
)
}
participant_profile <- pl_latest_participant |>
mutate(
income_group = collapse_income(income),
education_group = collapse_education(education),
education_binary = case_when(
education_group %in% c("Less than high school", "High school diploma or GED") ~ "High school or less",
education_group %in% c("Some college or associate degree", "Bachelor's degree or higher") ~ "Some college or higher",
TRUE ~ NA_character_
)
)
summarize_categorical <- function(data, variable, characteristic) {
data |>
filter(!is.na(.data[[variable]]), str_squish(.data[[variable]]) != "") |>
count(category = .data[[variable]], name = "n") |>
mutate(
characteristic = characteristic,
denominator = sum(n),
percent = n / denominator * 100
) |>
select(characteristic, category, n, denominator, percent)
}
demographic_results <- bind_rows(
summarize_categorical(participant_profile, "gender", "Gender"),
summarize_categorical(participant_profile, "income_group", "Household income"),
summarize_categorical(participant_profile, "education_group", "Education"),
summarize_categorical(participant_profile, "primary_language", "Primary language"),
summarize_categorical(participant_profile, "ethnicity", "Race and ethnicity"),
summarize_categorical(participant_profile, "employment", "Employment status")
)
kable(
demographic_results |>
mutate(percent = scales::percent(percent / 100, accuracy = 1)) |>
rename(
Characteristic = characteristic,
Category = category,
N = n,
Denominator = denominator,
Percent = percent
)
)
| Characteristic | Category | N | Denominator | Percent |
|---|---|---|---|---|
| Gender | F | 212 | 225 | 94% |
| Gender | M | 11 | 225 | 5% |
| Gender | Other/Otro | 2 | 225 | 1% |
| Household income | $50,000 or more | 76 | 119 | 64% |
| Household income | Under $50,000 | 43 | 119 | 36% |
| Education | Bachelor’s degree or higher | 89 | 197 | 45% |
| Education | High school diploma or GED | 29 | 197 | 15% |
| Education | Less than high school | 10 | 197 | 5% |
| Education | Other | 1 | 197 | 1% |
| Education | Some college or associate degree | 68 | 197 | 35% |
| Primary language | Arabic/Arábico | 1 | 224 | 0% |
| Primary language | English/Inglés | 170 | 224 | 76% |
| Primary language | Korean/Koreano | 1 | 224 | 0% |
| Primary language | Mandarin Chinese/Mandarin Chino (Putonghua) | 1 | 224 | 0% |
| Primary language | Other (Specify)/Otro (especifique): | 8 | 224 | 4% |
| Primary language | Spanish/Español | 43 | 224 | 19% |
| Race and ethnicity | Asian/Asiático | 7 | 225 | 3% |
| Race and ethnicity | Black/African American/Negro/Afroamericano | 3 | 225 | 1% |
| Race and ethnicity | Decline to answer/Prefiero no responder | 4 | 225 | 2% |
| Race and ethnicity | Hispanic/Hispano/Latino | 155 | 225 | 69% |
| Race and ethnicity | Multiracial/Razas múltiples | 5 | 225 | 2% |
| Race and ethnicity | Native Hawaiian or Other Pacific Islander/Nativo de Hawai o otro Isleños del PacÃfico | 1 | 225 | 0% |
| Race and ethnicity | Other/Otro | 6 | 225 | 3% |
| Race and ethnicity | White/Blanco | 44 | 225 | 20% |
| Employment status | Employed full-time | 49 | 203 | 24% |
| Employment status | Employed part-time | 28 | 203 | 14% |
| Employment status | No answer/prefer not to say | 3 | 203 | 1% |
| Employment status | Seasonal worker | 1 | 203 | 0% |
| Employment status | Stay at home parent | 86 | 203 | 42% |
| Employment status | Temporary Employment | 1 | 203 | 0% |
| Employment status | Unemployed | 35 | 203 | 17% |
Subgroup estimates are displayed only when each comparison group has at least 10 usable survey records.
pl_analysis_with_groups <- pl_episodes |>
mutate(
income_group = collapse_income(income),
education_group = collapse_education(education),
education_binary = case_when(
education_group %in% c("Less than high school", "High school diploma or GED") ~ "High school or less",
education_group %in% c("Some college or associate degree", "Bachelor's degree or higher") ~ "Some college or higher",
TRUE ~ NA_character_
)
)
subgroup_activity_summary <- function(data, group_variable, group_label) {
data |>
select(all_of(group_variable), ends_with("_pre"), ends_with("_post")) |>
rename(group = all_of(group_variable)) |>
filter(!is.na(group)) |>
pivot_longer(
cols = matches("^(read|talk|sing)_(pre|post)$"),
names_to = c("activity", "wave"),
names_pattern = "(read|talk|sing)_(pre|post)",
values_to = "value"
) |>
group_by(group, activity, wave) |>
summarize(n = sum(!is.na(value)), mean = mean(value, na.rm = TRUE), .groups = "drop") |>
group_by(group, activity) |>
filter(min(n) >= params$minimum_subgroup_n) |>
ungroup() |>
mutate(group_variable = group_label)
}
subgroup_activity_results <- bind_rows(
subgroup_activity_summary(pl_analysis_with_groups, "income_group", "Household income"),
subgroup_activity_summary(pl_analysis_with_groups, "education_binary", "Education")
) |>
mutate(wave = factor(wave, levels = c("pre", "post")))
if (nrow(subgroup_activity_results) > 0) {
ggplot(
subgroup_activity_results,
aes(x = wave, y = mean, group = group, colour = group)
) +
geom_line(linewidth = 1) +
geom_point(size = 3) +
geom_text(aes(label = number(mean, accuracy = .1)), vjust = -1, show.legend = FALSE) +
facet_grid(group_variable ~ activity, scales = "free_x") +
scale_x_discrete(labels = c(pre = "Before", post = "After")) +
scale_y_continuous(limits = c(0, 7.5), breaks = 0:7) +
labs(
title = "Weekly family activities by participant background",
x = NULL, y = "Average days per week", colour = NULL
)
} else {
cat("Current subgroup sizes do not meet the minimum reporting threshold.")
}
pi_long <- pi |>
select(participant_id, survey_date, matches("^pi_\\d{2}$")) |>
pivot_longer(
matches("^pi_\\d{2}$"),
names_to = "item_code",
values_to = "response"
) |>
mutate(item_number = as.integer(str_extract(item_code, "\\d+"))) |>
left_join(pi_dictionary, by = "item_number")
pi_results <- pi_long |>
filter(!is.na(response)) |>
count(domain, item_number, item_label, response, name = "n") |>
group_by(domain, item_number, item_label) |>
mutate(denominator = sum(n), percent = n / denominator * 100) |>
ungroup()
pi_top_box <- pi_results |>
group_by(domain, item_number, item_label, denominator) |>
summarize(
agree_or_strongly_agree = sum(percent[response %in% c("Agree", "Strongly Agree")]),
strongly_agree = sum(percent[response == "Strongly Agree"]),
.groups = "drop"
)
kable(
pi_top_box |>
transmute(
Domain = domain,
Item = item_label,
N = denominator,
`Agree or strongly agree` = scales::percent(agree_or_strongly_agree / 100, accuracy = 1),
`Strongly agree` = scales::percent(strongly_agree / 100, accuracy = 1)
)
)
| Domain | Item | N | Agree or strongly agree | Strongly agree |
|---|---|---|---|---|
| Parent and Family Outcomes | I spend more time playing and interacting with my child | 140 | 100% | 89% |
| Parent and Family Outcomes | I have opportunities to build strong relationships with other families | 140 | 100% | 71% |
| Parent and Family Outcomes | I have other families I can depend on for support | 140 | 96% | 54% |
| Parent and Family Outcomes | I am able to get the services my family needs | 138 | 100% | 71% |
| Parent and Family Outcomes | We have more consistent routines at home | 140 | 100% | 76% |
| Parent and Family Outcomes | I am better able to understand and respond to my child’s cues | 140 | 100% | 79% |
| Parent and Family Outcomes | I am better able to understand and support my child’s behavior | 140 | 100% | 84% |
| Services and Staff | Services and activities are offered at a convenient time and location | 139 | 100% | 78% |
| Services and Staff | Staff are welcoming and respectful | 139 | 100% | 94% |
| Services and Staff | Staff ask about my family’s concerns | 138 | 100% | 75% |
| Services and Staff | Staff follow up about my family’s concerns | 140 | 100% | 77% |
| Services and Staff | Staff respect my culture and traditions | 140 | 100% | 91% |
| Services and Staff | Staff communicate with me in my home language | 140 | 100% | 90% |
| Services and Staff | Staff respect my identity | 139 | 100% | 91% |
| Services and Staff | The program offers opportunities to learn about diversity, equity, and inclusion | 139 | 99% | 88% |
| Services and Staff | Staff value my feedback and ideas | 138 | 100% | 86% |
plot_likert_domain <- function(domain_name) {
plot_data <- pi_results |>
filter(domain == domain_name) |>
mutate(
response = factor(response, levels = likert_levels),
item_label = str_wrap(item_label, width = 46),
item_label = fct_rev(factor(item_label, levels = unique(item_label)))
)
ggplot(plot_data, aes(x = percent, y = item_label, fill = response)) +
geom_col(width = .72) +
geom_text(
aes(label = if_else(percent >= 4, scales::percent(percent / 100, accuracy = 1), "")),
position = position_stack(vjust = .5), size = 3
) +
scale_fill_manual(
values = c(
"Strongly Disagree" = "#78A641",
"Disagree" = colors$blue,
"Agree" = colors$after,
"Strongly Agree" = "#50AFCB"
),
drop = FALSE
) +
guides(fill = guide_legend(reverse = TRUE)) +
scale_x_continuous(labels = percent_format(scale = 1), expand = expansion(mult = c(0, .02))) +
labs(title = domain_name, x = NULL, y = NULL, fill = NULL)
}
pi_services_plot <- plot_likert_domain("Services and Staff")
pi_services_plot
pi_outcomes_plot <- plot_likert_domain("Parent and Family Outcomes")
pi_outcomes_plot
This comparison places the post-program activity ratings from the Parenting Ladder alongside the Program Improvement Survey ratings from returning families. It is a comparison of two groups, not a paired before-and-after test.
first_time_activities <- pl_episodes |>
select(read = read_post, talk = talk_post, sing = sing_post) |>
pivot_longer(everything(), names_to = "activity", values_to = "value") |>
mutate(group = "Parenting Ladder post (first-time)")
returning_activities <- pi |>
select(read, talk, sing) |>
pivot_longer(everything(), names_to = "activity", values_to = "value") |>
mutate(group = "Program Improvement (returning)")
first_returning_long <- bind_rows(first_time_activities, returning_activities)
first_returning_results <- first_returning_long |>
group_by(activity, group) |>
summarize(
n = sum(!is.na(value)),
mean = mean(value, na.rm = TRUE),
pct_6_7 = mean(value %in% 6:7, na.rm = TRUE) * 100,
.groups = "drop"
)
first_returning_tests <- map_dfr(c("read", "talk", "sing"), function(this_activity) {
first_values <- first_time_activities |> filter(activity == this_activity) |> pull(value) |> na.omit()
returning_values <- returning_activities |> filter(activity == this_activity) |> pull(value) |> na.omit()
test <- t.test(first_values, returning_values)
tibble(
activity = this_activity,
p_value = test$p.value,
mean_difference = mean(first_values) - mean(returning_values)
)
})
kable(
first_returning_results |>
left_join(first_returning_tests, by = "activity") |>
transmute(
Activity = str_to_title(activity),
Group = group,
N = n,
Mean = round(mean, 1),
`Percent 6 or 7` = scales::percent(pct_6_7 / 100, accuracy = 1),
`Group-comparison p` = format.pval(p_value, digits = 3, eps = .001)
)
)
| Activity | Group | N | Mean | Percent 6 or 7 | Group-comparison p |
|---|---|---|---|---|---|
| Read | Parenting Ladder post (first-time) | 235 | 6.1 | 69% | < 0.001 |
| Read | Program Improvement (returning) | 139 | 5.5 | 55% | < 0.001 |
| Sing | Parenting Ladder post (first-time) | 233 | 6.6 | 88% | 0.00228 |
| Sing | Program Improvement (returning) | 139 | 6.3 | 76% | 0.00228 |
| Talk | Parenting Ladder post (first-time) | 234 | 6.5 | 81% | 0.00193 |
| Talk | Program Improvement (returning) | 139 | 6.1 | 67% | 0.00193 |