Purpose

This script reproduces the student composite-score analyses reported in Tables 2 and 3 of the manuscript. The workflow is intentionally limited to the analyses required for those tables and the associated post hoc comparisons.

required_packages <- c("dplyr", "readxl", "psych", "lavaan", "knitr")
missing_packages <- required_packages[
  !vapply(required_packages, requireNamespace, logical(1), quietly = TRUE)
]

if (length(missing_packages) > 0) {
  stop(
    "Install the following packages before running this analysis: ",
    paste(missing_packages, collapse = ", ")
  )
}

options(digits = 6)

# Bonferroni-adjusted significance threshold used in the manuscript for
# the three composite-score comparisons within each subgroup analysis.
alpha_bonf <- 0.05 / 3

1. Import and quality filtering

find_student_file <- function() {
  file_name <- "AI4DS_Student_Survey_October_5_ 2025.xlsx"
  candidates <- c(
    file_name,
    file.path("data", file_name),
    file.path("..", "data", file_name)
  )

  found <- candidates[file.exists(candidates)]
  if (length(found) == 0) {
    stop(
      "Student survey Excel file not found. Expected the file in the repo root, ",
      "data/, or ../data/ relative to the working directory."
    )
  }
  found[[1]]
}

load_student_data <- function(path, min_duration = 120, min_progress = 25) {
  # The Qualtrics export contains short variable names in row 1,
  # question labels in row 2, and respondent data beginning in row 3.
  header <- readxl::read_excel(path, n_max = 1, col_names = FALSE)
  short_names <- as.character(unlist(header[1, ], use.names = FALSE))
  short_names <- short_names[!is.na(short_names) & short_names != ""]

  student_df <- readxl::read_excel(
    path,
    skip = 2,
    col_names = short_names,
    .name_repair = "minimal"
  )

  student_df |>
    dplyr::filter(
      `Duration (in seconds)` >= min_duration,
      Progress >= min_progress
    )
}

student_data_path <- find_student_file()
student_data <- load_student_data(student_data_path)

cat("Data file:", normalizePath(student_data_path), "\n")
## Data file: C:\Users\amhasan1\OneDrive - North Carolina A&T State University\Documents\GitHub\AI4DS\Data & RCode\data\AI4DS_Student_Survey_October_5_ 2025.xlsx
cat("Data MD5:", unname(tools::md5sum(student_data_path)), "\n")
## Data MD5: 81957b81331eabab6a7e93ca8c39c0f7
cat("Analytic sample after quality filters:", nrow(student_data), "students\n")
## Analytic sample after quality filters: 119 students
# The distributed repository dataset is the finalized analytic file.
# The manuscript reports n = 119 valid student responses.
if (nrow(student_data) != 119L) {
  stop(
    "Unexpected analytic sample size. Expected 119 students but found ",
    nrow(student_data),
    ". Verify the data-file version and filtering criteria."
  )
}

required_variables <- c(
  "Q3", "Q5",
  paste0("Q10_", 1:5),
  paste0("Q18_", 1:5),
  "Q12_1", "Q12_2",
  "Q21_1", "Q21_2", "Q21_4"
)

missing_variables <- setdiff(required_variables, names(student_data))
if (length(missing_variables) > 0) {
  stop(
    "Required variables are missing from the input file: ",
    paste(missing_variables, collapse = ", ")
  )
}

2. Academic-level classification and item scoring

# In the initial survey version, "Graduate Student" was inadvertently
# omitted from Q5. Review of respondents' reported majors established that
# respondents with missing Q5 values in this dataset were graduate students.
# These missing Q5 values are therefore recoded as "Graduate Student".
student_data <- student_data |>
  dplyr::mutate(
    Q5 = dplyr::coalesce(as.character(Q5), "Graduate Student"),
    class_group = dplyr::case_when(
      Q5 %in% c("Freshman", "Sophomore") ~ "Lower-Division",
      Q5 %in% c("Junior", "Senior") ~ "Upper-Division",
      Q5 == "Graduate Student" ~ "Graduate",
      TRUE ~ NA_character_
    ),
    class_group = factor(
      class_group,
      levels = c("Lower-Division", "Upper-Division", "Graduate")
    )
  )

score_familiarity <- function(x) {
  dplyr::recode(
    as.character(x),
    "Not at All Familiar" = 1,
    "Not Very Familiar" = 2,
    "Neutral" = 3,
    "Somewhat Familiar" = 4,
    "Very Familiar" = 5,
    .default = NA_real_
  )
}

score_awareness <- function(x) {
  dplyr::recode(
    as.character(x),
    "Not at All Aware" = 1,
    "Not Very Aware" = 2,
    "Neutral" = 3,
    "Somewhat Aware" = 4,
    "Very Aware" = 5,
    .default = NA_real_
  )
}

score_agreement <- function(x) {
  dplyr::recode(
    as.character(x),
    "Strongly Disagree" = 1,
    "Disagree" = 2,
    "Neutral" = 3,
    "Agree" = 4,
    "Strongly Agree" = 5,
    .default = NA_real_
  )
}

student_data <- student_data |>
  dplyr::mutate(
    dplyr::across(Q10_1:Q10_5, score_familiarity),
    dplyr::across(Q18_1:Q18_5, score_awareness),
    dplyr::across(
      c(Q12_1, Q12_2, Q21_1, Q21_2, Q21_4),
      score_agreement
    )
  )

3. Construct the composite scores

The three composite scores are arithmetic means of the available selected items for each respondent. A composite is set to NA only when all five constituent items are missing. The Perceptions composite uses the five conceptually selected items Q12_1, Q12_2, Q21_1, Q21_2, and Q21_4.

student_data <- student_data |>
  dplyr::rowwise() |>
  dplyr::mutate(
    Q10_comp = ifelse(
      all(is.na(dplyr::c_across(Q10_1:Q10_5))),
      NA_real_,
      mean(dplyr::c_across(Q10_1:Q10_5), na.rm = TRUE)
    ),
    Q18_comp = ifelse(
      all(is.na(dplyr::c_across(Q18_1:Q18_5))),
      NA_real_,
      mean(dplyr::c_across(Q18_1:Q18_5), na.rm = TRUE)
    ),
    Qper_comp = ifelse(
      all(is.na(dplyr::c_across(c(Q12_1, Q12_2, Q21_1, Q21_2, Q21_4)))),
      NA_real_,
      mean(
        dplyr::c_across(c(Q12_1, Q12_2, Q21_1, Q21_2, Q21_4)),
        na.rm = TRUE
      )
    )
  ) |>
  dplyr::ungroup()

composite_summary <- dplyr::bind_rows(
  data.frame(
    Composite = "Familiarity",
    n = sum(!is.na(student_data$Q10_comp)),
    Mean = mean(student_data$Q10_comp, na.rm = TRUE),
    SD = stats::sd(student_data$Q10_comp, na.rm = TRUE)
  ),
  data.frame(
    Composite = "Perceptions",
    n = sum(!is.na(student_data$Qper_comp)),
    Mean = mean(student_data$Qper_comp, na.rm = TRUE),
    SD = stats::sd(student_data$Qper_comp, na.rm = TRUE)
  ),
  data.frame(
    Composite = "Awareness of Limitations",
    n = sum(!is.na(student_data$Q18_comp)),
    Mean = mean(student_data$Q18_comp, na.rm = TRUE),
    SD = stats::sd(student_data$Q18_comp, na.rm = TRUE)
  )
) |>
  dplyr::mutate(
    Mean = round(Mean, 3),
    SD = round(SD, 3)
  )

knitr::kable(composite_summary, caption = "Overall composite-score summary.")
Overall composite-score summary.
Composite n Mean SD
Familiarity 109 2.800 0.973
Perceptions 108 3.814 0.578
Awareness of Limitations 102 4.112 0.866

4. Confirmatory factor analysis and reliability

CFA is conducted after the composite-score variables have been constructed. The CFA models use the five constituent items for each construct and explicitly use listwise deletion for missing CFA indicators. Cronbach’s alpha is calculated from the same item sets.

model_familiarity <- '
  Familiarity =~ Q10_1 + Q10_2 + Q10_3 + Q10_4 + Q10_5
'

model_perceptions <- '
  Perceptions =~ Q12_1 + Q12_2 + Q21_1 + Q21_2 + Q21_4
'

model_awareness <- '
  Awareness =~ Q18_1 + Q18_2 + Q18_3 + Q18_4 + Q18_5
'

fit_familiarity <- lavaan::cfa(
  model_familiarity,
  data = student_data,
  missing = "listwise"
)

fit_perceptions <- lavaan::cfa(
  model_perceptions,
  data = student_data,
  missing = "listwise"
)

fit_awareness <- lavaan::cfa(
  model_awareness,
  data = student_data,
  missing = "listwise"
)

alpha_familiarity <- psych::alpha(
  student_data |> dplyr::select(Q10_1:Q10_5)
)

alpha_perceptions <- psych::alpha(
  student_data |>
    dplyr::select(Q12_1, Q12_2, Q21_1, Q21_2, Q21_4)
)

alpha_awareness <- psych::alpha(
  student_data |> dplyr::select(Q18_1:Q18_5)
)

make_cfa_row <- function(label, fit, alpha_object) {
  data.frame(
    Composite = label,
    Chi_Square = unname(lavaan::fitMeasures(fit, "chisq")),
    df = as.integer(unname(lavaan::fitMeasures(fit, "df"))),
    P_value = unname(lavaan::fitMeasures(fit, "pvalue")),
    CFI = unname(lavaan::fitMeasures(fit, "cfi")),
    TLI = unname(lavaan::fitMeasures(fit, "tli")),
    RMSEA = unname(lavaan::fitMeasures(fit, "rmsea")),
    Raw_Alpha = alpha_object$total$raw_alpha,
    Standard_Alpha = alpha_object$total$std.alpha,
    stringsAsFactors = FALSE
  )
}

cfa_results <- dplyr::bind_rows(
  make_cfa_row("Familiarity", fit_familiarity, alpha_familiarity),
  make_cfa_row("Perceptions", fit_perceptions, alpha_perceptions),
  make_cfa_row("Awareness of Limitations", fit_awareness, alpha_awareness)
)

5. Table 2: CFA model fit and reliability

Table_2 <- cfa_results |>
  dplyr::transmute(
    Composite,
    `Chi-square Stat (p-value)` = sprintf("%.3f (%.3f)", Chi_Square, P_value),
    CFI = sprintf("%.3f", CFI),
    TLI = sprintf("%.3f", TLI),
    RMSEA = sprintf("%.3f", RMSEA),
    `Raw Alpha` = sprintf("%.3f", Raw_Alpha),
    `Standard Alpha` = sprintf("%.3f", Standard_Alpha)
  )

knitr::kable(
  Table_2,
  align = c("l", rep("c", 6)),
  caption = "Table 2. Model fit statistics from confirmatory factor analysis."
)
Table 2. Model fit statistics from confirmatory factor analysis.
Composite Chi-square Stat (p-value) CFI TLI RMSEA Raw Alpha Standard Alpha
Familiarity 8.875 (0.114) 0.969 0.939 0.086 0.756 0.753
Perceptions 8.309 (0.140) 0.971 0.942 0.081 0.766 0.767
Awareness of Limitations 3.320 (0.651) 1.000 1.017 0.000 0.859 0.860

For validation, the CFA degrees of freedom and number of observations used by each listwise CFA are retained below.

cfa_audit <- data.frame(
  Composite = c("Familiarity", "Perceptions", "Awareness of Limitations"),
  df = c(
    lavaan::fitMeasures(fit_familiarity, "df"),
    lavaan::fitMeasures(fit_perceptions, "df"),
    lavaan::fitMeasures(fit_awareness, "df")
  ),
  N_used = c(
    lavaan::lavInspect(fit_familiarity, "nobs"),
    lavaan::lavInspect(fit_perceptions, "nobs"),
    lavaan::lavInspect(fit_awareness, "nobs")
  )
)

knitr::kable(cfa_audit, caption = "CFA audit information (not part of manuscript Table 2).")
CFA audit information (not part of manuscript Table 2).
Composite df N_used
Familiarity 5 105
Perceptions 5 100
Awareness of Limitations 5 100

6. Subgroup analyses

The subgroup analyses are intentionally performed only after all composite-score and CFA calculations are complete. Academic-level comparisons use the full eligible analytic sample. Gender comparisons use only respondents who selected Female or Male.

6.1 Class-level comparisons

score_variables <- c(
  Familiarity = "Q10_comp",
  Perceptions = "Qper_comp",
  `Awareness of Limitations` = "Q18_comp"
)

class_summary_list <- lapply(names(score_variables), function(composite_name) {
  score_name <- score_variables[[composite_name]]

  student_data |>
    dplyr::filter(!is.na(class_group)) |>
    dplyr::group_by(class_group) |>
    dplyr::summarise(
      n = sum(!is.na(.data[[score_name]])),
      Mean = mean(.data[[score_name]], na.rm = TRUE),
      SD = stats::sd(.data[[score_name]], na.rm = TRUE),
      .groups = "drop"
    ) |>
    dplyr::mutate(Composite = composite_name, .before = 1)
})

class_summary <- dplyr::bind_rows(class_summary_list)

class_anova <- lapply(names(score_variables), function(composite_name) {
  score_name <- score_variables[[composite_name]]
  fit <- stats::aov(
    stats::reformulate("class_group", response = score_name),
    data = student_data
  )

  data.frame(
    Composite = composite_name,
    F = unname(summary(fit)[[1]]["class_group", "F value"]),
    P_value = unname(summary(fit)[[1]]["class_group", "Pr(>F)"]),
    stringsAsFactors = FALSE
  )
}) |>
  dplyr::bind_rows()

class_anova
##                  Composite         F     P_value
## 1              Familiarity 8.6524132 0.000330573
## 2              Perceptions 0.0862061 0.917469972
## 3 Awareness of Limitations 5.6971339 0.004550815

Tukey post hoc comparisons reported in the manuscript

get_tukey <- function(score_name, composite_name) {
  fit <- stats::aov(
    stats::reformulate("class_group", response = score_name),
    data = student_data
  )
  tukey <- as.data.frame(stats::TukeyHSD(fit)$class_group)
  tukey$Comparison <- rownames(tukey)
  rownames(tukey) <- NULL

  tukey |>
    dplyr::transmute(
      Composite = composite_name,
      Comparison,
      Difference = round(diff, 3),
      Lower_CI = round(lwr, 3),
      Upper_CI = round(upr, 3),
      Adjusted_P = round(`p adj`, 3)
    )
}

# The manuscript reports post hoc results only for constructs with a
# significant omnibus class-level ANOVA.
tukey_results <- dplyr::bind_rows(
  get_tukey("Q10_comp", "Familiarity"),
  get_tukey("Q18_comp", "Awareness of Limitations")
)

knitr::kable(
  tukey_results,
  caption = "Tukey HSD post hoc comparisons for significant class-level ANOVAs."
)
Tukey HSD post hoc comparisons for significant class-level ANOVAs.
Composite Comparison Difference Lower_CI Upper_CI Adjusted_P
Familiarity Upper-Division-Lower-Division 0.425 -0.085 0.935 0.122
Familiarity Graduate-Lower-Division 1.013 0.430 1.597 0.000
Familiarity Graduate-Upper-Division 0.588 0.084 1.092 0.018
Awareness of Limitations Upper-Division-Lower-Division 0.548 0.068 1.028 0.021
Awareness of Limitations Graduate-Lower-Division 0.752 0.194 1.310 0.005
Awareness of Limitations Graduate-Upper-Division 0.204 -0.276 0.684 0.572

6.2 Gender comparisons

# Restrict only the gender-comparison analysis to respondents who selected
# Female or Male. The master student_data object remains unchanged.
gender_data <- student_data |>
  dplyr::filter(Q3 %in% c("Female", "Male")) |>
  dplyr::mutate(Q3 = factor(Q3, levels = c("Female", "Male")))

gender_summary_list <- lapply(names(score_variables), function(composite_name) {
  score_name <- score_variables[[composite_name]]

  gender_data |>
    dplyr::group_by(Q3) |>
    dplyr::summarise(
      n = sum(!is.na(.data[[score_name]])),
      Mean = mean(.data[[score_name]], na.rm = TRUE),
      SD = stats::sd(.data[[score_name]], na.rm = TRUE),
      .groups = "drop"
    ) |>
    dplyr::mutate(Composite = composite_name, .before = 1)
})

gender_summary <- dplyr::bind_rows(gender_summary_list)

gender_tests <- lapply(names(score_variables), function(composite_name) {
  score_name <- score_variables[[composite_name]]
  test <- stats::t.test(
    stats::reformulate("Q3", response = score_name),
    data = gender_data,
    var.equal = FALSE
  )

  data.frame(
    Composite = composite_name,
    t = unname(test$statistic),
    df = unname(test$parameter),
    P_value = test$p.value,
    stringsAsFactors = FALSE
  )
}) |>
  dplyr::bind_rows()

gender_tests
##                  Composite         t      df     P_value
## 1              Familiarity -0.724643 68.5186 0.471138143
## 2              Perceptions -1.453337 76.7824 0.150205253
## 3 Awareness of Limitations -3.575149 96.2851 0.000549378

7. Table 3: Composite-score comparisons by gender and class level

Table 3 is generated directly from the subgroup summaries and hypothesis tests above. All displayed numeric values are rounded to three decimal places. Asterisks identify p-values below the Bonferroni-adjusted threshold of 0.0167 used in the manuscript.

format_mean_sd <- function(mean_value, sd_value) {
  sprintf("%.3f (%.3f)", mean_value, sd_value)
}

format_p <- function(p_value) {
  if (is.na(p_value)) return("")
  paste0(
    sprintf("%.3f", p_value),
    ifelse(p_value < alpha_bonf, "*", "")
  )
}

extract_summary <- function(summary_df, composite_name, group_column, group_name) {
  row <- summary_df[
    summary_df$Composite == composite_name &
      as.character(summary_df[[group_column]]) == group_name,
    ,
    drop = FALSE
  ]

  if (nrow(row) != 1) {
    stop("Could not uniquely identify summary row for ", composite_name, " / ", group_name)
  }

  list(
    n = row$n,
    mean_sd = format_mean_sd(row$Mean, row$SD)
  )
}

get_test_p <- function(test_df, composite_name) {
  test_df$P_value[test_df$Composite == composite_name][1]
}

# The manuscript displays p-values once per comparison: on the Male row for
# Welch gender tests and on the Graduate row for three-level ANOVA tests.
make_table3_row <- function(group_name, comparison_type) {
  if (comparison_type == "gender") {
    source_df <- gender_summary
    group_col <- "Q3"
    p_source <- gender_tests
    show_p <- group_name == "Male"
  } else {
    source_df <- class_summary
    group_col <- "class_group"
    p_source <- class_anova
    show_p <- group_name == "Graduate"
  }

  fam <- extract_summary(source_df, "Familiarity", group_col, group_name)
  per <- extract_summary(source_df, "Perceptions", group_col, group_name)
  awa <- extract_summary(source_df, "Awareness of Limitations", group_col, group_name)

  data.frame(
    Group = group_name,
    Familiarity_n = fam$n,
    `Familiarity Mean (SD)` = fam$mean_sd,
    `Familiarity P-value` = if (show_p) format_p(get_test_p(p_source, "Familiarity")) else "",
    Perceptions_n = per$n,
    `Perceptions Mean (SD)` = per$mean_sd,
    `Perceptions P-value` = if (show_p) format_p(get_test_p(p_source, "Perceptions")) else "",
    Awareness_n = awa$n,
    `Awareness Mean (SD)` = awa$mean_sd,
    `Awareness P-value` = if (show_p) format_p(get_test_p(p_source, "Awareness of Limitations")) else "",
    check.names = FALSE,
    stringsAsFactors = FALSE
  )
}

# Retain the manuscript row order while keeping the analysis code itself in
# the requested order: class-level analysis first, then gender analysis.
Table_3 <- dplyr::bind_rows(
  make_table3_row("Female", "gender"),
  make_table3_row("Male", "gender"),
  make_table3_row("Lower-Division", "class"),
  make_table3_row("Upper-Division", "class"),
  make_table3_row("Graduate", "class")
)

knitr::kable(
  Table_3,
  align = c("l", rep("c", 9)),
  caption = "Table 3. Composite score comparisons by gender and class levels."
)
Table 3. Composite score comparisons by gender and class levels.
Group Familiarity_n Familiarity Mean (SD) Familiarity P-value Perceptions_n Perceptions Mean (SD) Perceptions P-value Awareness_n Awareness Mean (SD) Awareness P-value
Female 69 2.695 (0.921) 70 3.767 (0.591) 65 3.920 (0.940)
Male 36 2.836 (0.961) 0.471 36 3.933 (0.539) 0.150 35 4.463 (0.576) 0.001*
Lower-Division 27 2.330 (0.748) 28 3.811 (0.579) 25 3.648 (0.947)
Upper-Division 54 2.755 (0.914) 54 3.796 (0.556) 52 4.196 (0.818)
Graduate 28 3.343 (1.037) 0.000* 26 3.854 (0.639) 0.917 25 4.400 (0.716) 0.005*

Table 3 note. Gender comparisons use Welch’s two-sample t-test. Class-level comparisons use one-way ANOVA. The manuscript applies a Bonferroni-adjusted significance threshold of 0.05/3 = 0.0167 across the three composite-score tests within each subgroup analysis. Tukey HSD is used for post hoc class-level comparisons following significant omnibus ANOVAs.

8. Reproducibility information

sessionInfo()
## R version 4.5.2 (2025-10-31 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=English_United States.utf8 
## [2] LC_CTYPE=English_United States.utf8   
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C                          
## [5] LC_TIME=English_United States.utf8    
## 
## time zone: America/New_York
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## loaded via a namespace (and not attached):
##  [1] jsonlite_2.0.0    dplyr_1.1.4       compiler_4.5.2    tidyselect_1.2.1 
##  [5] psych_2.5.6       parallel_4.5.2    jquerylib_0.1.4   yaml_2.3.10      
##  [9] fastmap_1.2.0     readxl_1.4.5      lattice_0.22-7    pbivnorm_0.6.0   
## [13] R6_2.6.1          generics_0.1.4    knitr_1.50        tibble_3.3.0     
## [17] bslib_0.9.0       pillar_1.11.1     rlang_1.1.6       cachem_1.1.0     
## [21] xfun_0.54         quadprog_1.5-8    sass_0.4.10       cli_3.6.5        
## [25] withr_3.0.2       magrittr_2.0.4    digest_0.6.38     grid_4.5.2       
## [29] rstudioapi_0.17.1 lifecycle_1.0.4   nlme_3.1-168      lavaan_0.6-21    
## [33] vctrs_0.6.5       mnormt_2.1.1      evaluate_1.0.5    glue_1.8.0       
## [37] cellranger_1.1.0  stats4_4.5.2      rmarkdown_2.30    tools_4.5.2      
## [41] pkgconfig_2.0.3   htmltools_0.5.8.1