Prep Data

Author

Jeffrey Girard

Published

July 24, 2026

library(tidyverse)
library(janitor)
library(vroom)
library(facs) #pak::pak("jmgirard/facs")
library(furrr)

# Set up parallel processing
plan("multisession", workers = 4)

Tidy FACS Files

# Specify input directory
indir <- "/mnt/datasets/SEEDLingS/1_RAWDATA/FACS"

# Find all CSV files in input directory
infiles <-
  list.files(
    path = indir,
    pattern = ".csv$",
    full.names = TRUE,
    recursive = TRUE
  ) %>%
  # Set vector names for later capture as id
  set_names(.)

# In parallel, read in all FACS files to a single list object
facs_raw <-
  future_map(
    # For each files in infiles...
    infiles,
    # ...read in a comma-delimited file as character data and clean names
    \(x) vroom(
      file = x,
      delim = ",",
      na = c("", ".", ",", ",.", ".h", ".x", "="),
      col_types = cols(.default = col_character()),
      .name_repair = make_clean_names
    )
  )

Part 1: FACS Codes

facs1_raw <-
  # Select only the desired part 1 columns
  map(facs_raw, \(x) select(x, facs_ordinal:facs_identity)) |>
  # Combine all files into a single data frame
  bind_rows(.id = "filename") |>
  # Drop any rows that contain no data
  drop_na(facs_ordinal)
# Tidy the raw data for analysis
facs1_tidy <-
  facs1_raw |>
  mutate(
    # Remove master directory from filename
    filename = str_remove(filename, indir),
    # Convert event to integer
    facs_id = as.integer(facs_ordinal),
    # Convert onset and offset to integers
    facs_onset = as.integer(facs_onset),
    facs_offset = as.integer(facs_offset),
    # Calculate duration
    facs_duration = facs_offset - facs_onset,
    # If desired, convert ms to sec
    across(c(facs_onset, facs_offset, facs_duration), \(x) x / 1000),
    # Create FACS codes
    facs_raw = facs_facs_codes,
    facs_code = facs::coding(facs_raw),
    # Keep the identity of the coded face (e.g., infant vs. caregiver). This is
    # a per-event grouping key: events from different faces can overlap in time
    # within one recording, so downstream analyses must group by it and must not
    # pool identities together.
    facs_identity = facs_identity,
    # Drop all unused columns
    .keep = "none"
  ) |>
  # Separate filename into wave, subject, and month
  separate_wider_delim(
    cols = filename,
    delim = regex("[./_]"),
    names = c(NA, NA, "wave", NA, "subject", "month", NA)
  ) |>
  # Convert wave, subject, and month to integers
  mutate(across(c(wave, subject, month), as.integer)) |>
  # Standardize identity labels BEFORE any export or downstream use. Trimming
  # whitespace and lowercasing collapses accidental variants (e.g. "f1S"/"f1s")
  # onto one label so a single face is not split across rows. NOTE: this only
  # fixes case/whitespace; genuinely different strings for the same face (e.g.
  # "f1" vs "father") must be reconciled by hand after inspecting the counts
  # below.
  mutate(facs_identity = facs_identity |> str_to_lower() |> str_trim()) |>
  # Relocate subject and identity to the front
  relocate(subject, facs_identity, .before = 1)
# Inspect the identity values AFTER standardization but BEFORE export. Watch for
# (a) events with no identity coded (NA), and (b) any remaining inconsistent
# labels for the same face that case/whitespace normalization did not catch.
facs1_tidy |> count(facs_identity, sort = TRUE)
# A tibble: 15 × 2
   facs_identity     n
   <chr>         <int>
 1 f1             8996
 2 c1             2583
 3 m1             2179
 4 f2              386
 5 f3              223
 6 c2              149
 7 c3               56
 8 s                49
 9 m2               16
10 f1s               3
11 1                 2
12 f                 2
13 <NA>              2
14 g1                1
15 r2                1
# Identities present within a single recording (>1 means faces share a timeline)
facs1_tidy |>
  summarize(n_identities = n_distinct(facs_identity), .by = c(subject, wave, month)) |>
  count(n_identities)
# A tibble: 6 × 2
  n_identities     n
         <int> <int>
1            1    48
2            2    48
3            3    19
4            4     5
5            5     2
6            6     1
# One zero-length event (offset == onset) exists; downstream analyses keep only
# duration > 0, so it is harmless, but flag the count for the record.
cat("facs zero-length events:", sum(facs1_tidy$facs_duration == 0, na.rm = TRUE), "\n")
facs zero-length events: 1 
# No negative FACS durations were found; guard anyway so a future re-run with
# new data cannot slip an impossible interval through.
stopifnot(all(facs1_tidy$facs_duration >= 0, na.rm = TRUE))
# Export tidy data to CSV and RDS for posterity
write_csv(facs1_tidy, file = "facs_coding.csv")
write_rds(facs1_tidy, file = "facs_coding.rds", compress = "gz")
# Rows whose identity is missing or a short/ambiguous label worth manual review.
# Labels are already lowercased, so match in lower case.
facs1_tidy |>
  filter(is.na(facs_identity) | facs_identity %in% c("s", "1", "f", "f1s")) |>
  arrange(subject, wave, facs_onset) |>
  write_csv("check_identity.csv")

Part 2: Partial Faces Visible

facs2_raw <-
  map(
    # For each data frame in facs_raw...
    facs_raw,
    \(x) x |>
      # Select only the desired part 2 columns
      select(starts_with("face_present_")) |>
      # Harmonize column names (code and code01 -> code)
      rename_with(\(y) str_remove(y, "01"))
  ) |>
  # Combine all files into a single data frame
  bind_rows(.id = "filename") |>
  # Drop any rows that contain no data
  drop_na(face_present_ordinal)
# Tidy the raw data for analysis (see Part 1 for comments).
# NOTE: `face_present_*` columns carry PARTIAL-face intervals and include an
# identity column (partial faces are coded per face). Full-face intervals are in
# the `partial_*` columns handled in Part 3. The prefixes in the raw file are
# counterintuitive; the mapping is verified against the occlusion inventory
# downstream (neu + auc == ffa - vis - hat), so do not "fix" the prefixes here.
facs2_tidy <-
  facs2_raw |>
  drop_na(face_present_identity) |>
  mutate(
    filename = str_remove(filename, indir),
    part_id = as.integer(face_present_ordinal),
    part_onset = as.integer(face_present_onset),
    part_offset = as.integer(face_present_offset),
    part_duration = part_offset - part_onset,
    across(c(part_onset, part_offset, part_duration), \(x) x / 1000),
    part_identity = face_present_identity,
    .keep = "none"
  ) |>
  separate_wider_delim(
    cols = filename,
    delim = regex("[./_]"),
    names = c(NA, NA, "wave", NA, "subject", "month", NA)
  ) |>
  mutate(across(c(subject, wave, month), as.integer)) |>
  # Standardize identity the same way as Part 1
  mutate(part_identity = part_identity |> str_to_lower() |> str_trim()) |>
  relocate(subject, .before = 1)
# Two kinds of bad interval exist in the raw partial-face coding:
#  (A) offset coded as 0 with a real onset: the offset is MISSING, so the
#      interval is unrecoverable. Drop these rows.
#  (B) offset a hair below onset (sub-second): rounding. These are effectively
#      zero-length and are removed downstream by `part_duration > 0`, so we
#      leave them in the saved file but do not treat them as an error.
bad_offset_2 <- facs2_tidy$part_offset == 0 & facs2_tidy$part_onset > 0
cat("partface rows dropped (offset missing/zero):", sum(bad_offset_2), "\n")
partface rows dropped (offset missing/zero): 8 
cat("partface sub-second negative (rounding, kept):",
    sum(facs2_tidy$part_duration < 0 & !bad_offset_2), "\n")
partface sub-second negative (rounding, kept): 9 
facs2_tidy <- facs2_tidy |> filter(!(part_offset == 0 & part_onset > 0))

# After dropping the unrecoverable rows, the only remaining negatives should be
# tiny rounding artifacts (magnitude < 1 s). Fail on anything larger.
stopifnot(all(facs2_tidy$part_duration >= -1))
# Export tidy data to CSV and RDS for posterity
write_csv(facs2_tidy, file = "partface_coding.csv")
write_rds(facs2_tidy, file = "partface_coding.rds", compress = "gz")

Part 3: Full Faces Visible

facs3_raw <-
  # In parallel, read part 3 from all FACS files
  map(
    facs_raw,
    \(x) x |>
      # Select only the desired columns (full-face intervals; see note in Part 2)
      select(starts_with("partial_")) |>
      # Harmonize column names (code and code01 -> code)
      rename_with(\(y) str_remove(y, "01"))
  ) |>
  # Combine all files into a single data frame
  bind_rows(.id = "filename") |>
  # Drop any rows that contain no data
  drop_na(partial_ordinal)
# Tidy the raw data for analysis
facs3_tidy <-
  facs3_raw |>
  mutate(
    filename = str_remove(filename, indir),
    full_id = as.integer(partial_ordinal),
    full_onset = as.integer(partial_onset),
    full_offset = as.integer(partial_offset),
    full_duration = full_offset - full_onset,
    across(c(full_onset, full_offset, full_duration), \(x) x / 1000),
    .keep = "none"
  ) |>
  separate_wider_delim(
    cols = filename,
    delim = regex("[./_]"),
    names = c(NA, NA, "wave", NA, "subject", "month", NA)
  ) |>
  mutate(across(c(subject, wave, month), as.integer)) |>
  relocate(subject, .before = 1)
# Same two problems as Part 2, plus one larger reversed interval to inspect.
#  (A) offset == 0 with a real onset: unrecoverable, drop.
#  (B) sub-second negatives: rounding, kept (removed downstream by > 0).
#  (C) subject 26 wave 9 shows a ~21.6 s reversed interval. This is too large to
#      be rounding and looks like an onset/offset transposition. We swap it so
#      the interval is recovered rather than lost; if manual review shows it is
#      not a transposition, drop it instead.
bad_offset_3 <- facs3_tidy$full_offset == 0 & facs3_tidy$full_onset > 0
cat("fullface rows dropped (offset missing/zero):", sum(bad_offset_3), "\n")
fullface rows dropped (offset missing/zero): 1 
facs3_tidy <- facs3_tidy |> filter(!(full_offset == 0 & full_onset > 0))

# Repair the single large reversed interval (subject 26, wave 9) by swapping
# onset and offset. Guarded to that one row so nothing else is touched.
facs3_tidy <-
  facs3_tidy |>
  mutate(
    swap = full_duration < -1,   # only the ~-21.6 s row qualifies after (A) is gone
    .o = full_onset,
    full_onset  = if_else(swap, full_offset, full_onset),
    full_offset = if_else(swap, .o, full_offset),
    full_duration = full_offset - full_onset
  ) |>
  select(-swap, -.o)

cat("fullface reversed intervals swapped:",
    sum(facs3_tidy$full_duration < 0 & facs3_tidy$full_duration >= -1) == 0, "\n")
fullface reversed intervals swapped: TRUE 
# Only sub-second rounding negatives may remain.
stopifnot(all(facs3_tidy$full_duration >= -1))
# Export tidy data to CSV and RDS for posterity
write_csv(facs3_tidy, file = "fullface_coding.csv")
write_rds(facs3_tidy, file = "fullface_coding.rds", compress = "gz")

Part 4: Session Information

facs4_raw <-
  # Select only the desired part 1 columns
  map(facs_raw, \(x) select(x, starts_with("session_"))) |>
  # Combine all files into a single data frame
  bind_rows(.id = "filename") |>
  # Drop any rows that contain no data
  drop_na(session_ordinal)
# Tidy the raw data for analysis
facs4_tidy <-
  facs4_raw |>
  mutate(
    filename = str_remove(filename, indir),
    session_onset = as.integer(session_onset),
    session_offset = as.integer(session_offset),
    session_duration = session_offset - session_onset,
    across(c(session_onset, session_offset, session_duration), \(x) x / 1000),
    .keep = "none"
  ) |>
  separate_wider_delim(
    cols = filename,
    delim = regex("[./_]"),
    names = c(NA, NA, "wave", NA, "subject", "month", NA)
  ) |>
  mutate(across(c(subject, wave, month), as.integer)) |>
  relocate(subject, .before = 1)
# Export tidy data to CSV and RDS for posterity
write_csv(facs4_tidy, file = "session_info.csv")
write_rds(facs4_tidy, file = "session_info.rds", compress = "gz")
# Fail loudly if the filename parse produced impossible values, rather than
# letting a mis-split path flow silently into every downstream analysis.
walk(
  list(facs1 = facs1_tidy, part = facs2_tidy, full = facs3_tidy, sess = facs4_tidy),
  \(d) {
    stopifnot(all(d$wave %in% c(6, 9, 12, 15)))
    stopifnot(!any(is.na(d$subject)))
    stopifnot(all(d$subject >= 1, na.rm = TRUE))
  }
)

# Sessions must be strictly positive
stopifnot(all(facs4_tidy$session_duration > 0, na.rm = TRUE))

cat("FACS files parsed and validated.\n")
FACS files parsed and validated.

Tidy Transcript Files

# Specify input directory
indir <- "/mnt/datasets/SEEDLingS/1_RAWDATA/Transcriptions"

# Find all CSV files in input directory
infiles <-
  list.files(
    path = indir,
    pattern = ".csv$",
    full.names = TRUE,
    recursive = TRUE
  ) %>%
  # Set vector names for later capture as id
  set_names(.)

# In parallel, read in all Transcript files to a single list object
text_raw <-
  future_map(
    # For each files in infiles...
    infiles,
    # ...read in a comma-delimited file as character data and clean names
    \(x) vroom(
      file = x,
      delim = ",",
      na = c("", "."),
      col_types = cols(.default = col_character()),
      .name_repair = make_clean_names
    )
  )
text1_raw <-
  # Select only the desired part 1 columns
  map(text_raw, \(x) select(x, starts_with("language"))) |>
  # Combine all files into a single data frame
  bind_rows(.id = "filename") |>
  # Drop any rows that contain no data
  drop_na(language_ordinal)
# Tidy the raw data for analysis.
# The transcript path has one fewer leading component than the FACS path, so the
# names vector here is 6 pieces, not 7. `wave` carries a text prefix, so it is
# parsed with parse_number rather than positionally.
text1_tidy <-
  text1_raw |>
  mutate(
    # Remove master directory from filename
    filename = str_remove(filename, indir),
    # Convert event to integer
    line_id = as.integer(language_ordinal),
    # Convert onset and offset to integers
    line_onset = as.integer(language_onset) / 1000,
    # Rename line text
    line_text = language_speech,
    # Drop all unused columns
    .keep = "none"
  ) |>
  # Separate filename into wave, subject, and month
  separate_wider_delim(
    cols = filename,
    delim = regex("[./_]"),
    names = c(NA, "wave", NA, "subject", "month", NA)
  ) |>
  # Convert wave, subject, and month to integers
  mutate(
    wave = parse_number(wave),
    across(c(wave, subject, month), as.integer)
  ) |>
  # Relocate subject to be the first column
  relocate(subject, .before = 1)
# Validate the transcript parse the same way as the FACS files.
stopifnot(all(text1_tidy$wave %in% c(6, 9, 12, 15)))
stopifnot(!any(is.na(text1_tidy$subject)))
cat("Transcript files parsed and validated.\n")
Transcript files parsed and validated.
# Export tidy data to CSV and RDS for posterity
write_csv(text1_tidy, file = "transcripts.csv")
write_rds(text1_tidy, file = "transcripts.rds", compress = "gz")