--- title: "Powerful Data Validation Engine" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Powerful Data Validation Engine} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r} #| label: setup #| include: false knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` ```{r} library(tidyr) library(dplyr, warn.conflicts = FALSE) library(tibble) library(glue) library(ggplot2) library(ksCompare) ``` # Introduction Data validation is a critical component of any data analysis pipeline. In this article, we'll explore a powerful validation framework centered around the `ks_check_rules()` function from the `ksCompare` package. This function provides a flexible, declarative approach to validating data frames with comprehensive error reporting. | Function | Purpose | |------------------------------------|------------------------------------| | `ks_check_rules` | apply validation rules row-wise | | `ks_collapse_check_msgs` | flatten list-column to character | | `ks_check_summary` | programmatic overview + violation breakdown | | `ks_check_report_html` | self-contained HTML check report | | `ks_compare_check_state` | diff two validation runs (new / changed / unchanged) | | `ks_save2xlsx_by` | split-by-group XLSX with conditional formatting | # Overview The `ks_check_rules()` function we introduced implements a flexible rule-based validation system for data frames. It enables specifying validation rules as a list of named rule objects, applying them against your data, and collecting results systematically. ## Key Features - **Declarative rules:** Define validation logic in a clean, reusable structure - **Two evaluation modes:** `"first"` (stops at first failure) vs. `"all"` (checks all rules) - **Rich diagnostics:** Per-row messages; collapse to a plain character column with `ks_collapse_check_msgs()` - **Programmatic summary:** `ks_check_summary()` returns a tidy overview and violation-by-rule breakdown without writing any file - **Self-contained HTML report:** `ks_check_report_html()` writes a single portable `.html` file — no Quarto, knitr, or internet access required - **Run-over-run comparison:** `ks_compare_check_state()` tags rows as `"new"`, `"changed"`, or unchanged relative to a previous snapshot - **XLSX output with highlighting:** `ks_save2xlsx_by()` splits results into one worksheet per group and applies conditional row colouring driven by the message column # Core Concepts ## Rule Structure Each rule is a list containing two required elements: ```{r, eval=FALSE} rule <- list( expr = ~ CONDITION_HERE, # One-sided formula with logical expression msg = "Error message" # Message for failing rows ) ``` **Important:** The expression should evaluate to `TRUE` for *failing* rows. While this may seem counter-intuitive at first, it follows the convention of defining "failure conditions" rather than "passing conditions." ## Data Structure Your data frame can contain any mix of variable types. Rules will be applied row-wise. ## Result Structure The return value is your original data frame with an additional column called `check_msgs`, a list-column where each element contains: - `"OK"` for rows that pass all validation rules - In `"first"` mode: The first failing message (or `"OK"`) - In `"all"` mode: A character vector of all violated messages (or `"OK"`) # Usage Examples ## Complete Example ### Example data: ```{r} # Sample data with intentional errors adsl <- tibble( USUBJID = sprintf("01-%03d", 1:6), AGE = c(34, 52, -1, -3, 29, 70), SEX = c("M", "F", "M", NA, "F", "X"), WEIGHT = c(70, 80, 75, 500, 65, 68) ) ``` ### Validation rules **Best practice:** Group related rules together and use descriptive names. While not mandatory, named rules improve code maintainability and make debugging easier: ```{r} # Define validation rules rules <- list( # Rule 1: Age must be non-negative age_non_negative = list( expr = ~ AGE < 0, msg = "AGE must be non-negative" ), # Rule 2: SEX cannot be missing sex_not_missing = list( expr = ~ is.na(SEX), msg = "SEX is missing" ), # Rule 3: SEX must be M or F (ignoring missing) sex_valid = list( expr = ~ !is.na(SEX) & !(SEX %in% c("M", "F")), msg = "SEX must be M/F" ), # Rule 4: WEIGHT within reasonable range weight_range = list( expr = ~ WEIGHT > 300, msg = "WEIGHT out of range" ) ) ``` Let's extend our rules with some advanced expressions. We can use regular expressions easily: ```{r} rules <- append(rules, list( # --- Study Subject Identifier --- usubjid_format = list( expr = ~ !grepl("^\\d{2}-\\d{3}$", USUBJID), msg = "USUBJID must match format: XX-XXX-XXX" ) )) ks_check_rules(adsl, rules, mode = "all") |> ks_collapse_check_msgs(col = check_msgs) ``` Apply rules in first-failing mode: ```{r} result_first <- ks_check_rules(adsl, rules, mode = "first") |> ks_collapse_check_msgs(col = check_msgs) result_first[, c("USUBJID", "check_msgs")] ``` Apply rules collecting all failures: ```{r} result_all <- ks_check_rules(adsl, rules, mode = "all") |> ks_collapse_check_msgs(col = check_msgs) result_all[, c("USUBJID", "check_msgs")] ``` # Mode Comparison | Feature | First Mode | All Mode | |----------------------|-----------------------------|----------------------| | Row processing | Stops at first failure | Checks all rules | | Performance | Faster (shorter evaluation) | Slower (complete check) | | Result detail | Minimalist (one message max) | Complete (all relevant messages) | | Use case | General validation | Thorough validation | ## When to Use Which? ```{r} # Typical use cases # Quick validation - stop at first issue found quick_check <- ks_check_rules(adsl, rules, mode = "first") quick_check # Comprehensive validation - get all issues for full validation full_audit <- ks_check_rules(adsl, rules, mode = "all") full_audit # Quality assurance check - ensure no errors exist strict_validation <- function(data, rules) { result <- ks_check_rules(data, rules, mode = "all") valid <- purrr::map_lgl(result$check_msgs, ~ "OK" %in% .x) if (!all(valid)) { msgs <- unique(unlist(result$check_msgs[!valid])) stop( paste0( "Validation failed:\n", paste0(" - ", msgs, collapse = "\n") ), call. = FALSE ) } invisible(result) } # Demonstrate the strict validation error handling tryCatch( strict_validation(adsl, rules), error = function(e) { cat("Error caught as expected:\n") cat(conditionMessage(e), "\n") } ) ``` ## Performance Tips 1. **Order matters in "first" mode:** Place faster-to-evaluate rules first for performance gain 2. **Group related columns:** Organize rules by data columns for better organization 3. **Use vectorized operations:** Write expressions using vectorized functions # Error Message Design Follow these guidelines for effective messages: 1. **Be specific:** Identify exactly what's wrong 2. **Suggest correction:** Include acceptable formats when helpful 3. **Reference standards:** Mention relevant regulations or guidelines 4. **Use consistent format:** Maintain uniform wording conventions ``` GOOD: "Date format must be YYYY-MM-DD. Value: '01/01/2025' is invalid." BAD: "Invalid date" ``` # Advanced Usage ```{r} # Using rlang expressions for dynamic rules dynamic_age <- function(threshold_age) { list( age_threshold = list( expr = rlang::new_formula( lhs = NULL, rhs = rlang::expr(AGE < !!threshold_age) ), msg = sprintf( "Age must be at least %d years", threshold_age ) ) ) } # Define rule validation_age <- dynamic_age(18) ``` We can check our dynamic rules. - **Rule:** `validation_age$age_threshold$expr` =\> `r paste(deparse(validation_age$age_threshold$expr), collapse = " ")`. - **Message:** `validation_age$age_threshold$msg` =\> `r validation_age$age_threshold$msg`. You can also verify the expression evaluates correctly: ```{r} rlang::eval_tidy( rlang::f_rhs(validation_age$age_threshold$expr), data = adsl ) # Expected: # [1] FALSE FALSE TRUE TRUE FALSE FALSE result <- ks_check_rules(adsl, validation_age) result |> mutate( check_msgs = purrr::map_chr(check_msgs, ~ paste(.x, collapse = ", ")) ) ``` ## Categorizing Violations You can post-process results to categorize violations: ```{r} classify_violations <- function(df) { df %>% mutate( violation_type = purrr::map_chr( check_msgs, ~ if ("OK" %in% .x) "clean" else "dirty" ), num_violations = purrr::map_int( check_msgs, ~ if ("OK" %in% .x) 0L else n_distinct(.x) ) ) } validation_result <- ks_check_rules(adsl, rules, mode = "all") classified_data <- classify_violations(validation_result) classified_data ``` ## Generating Summary Reports `ks_check_summary()` returns a named list with two tibbles — `overview` (one-row totals) and `violations` (per-message counts and percentages) — without writing any file: ```{r} summary <- ks_check_summary(validation_result) # Single-row KPI tibble summary$overview # Per-message breakdown (sorted by count descending) summary$violations ``` For a quick console printout you can format the results yourself: ```{r} cat(sprintf( "Rows: %d | Passing: %d | Failing: %d | Pass rate: %.1f%%\n", summary$overview$rows_total, summary$overview$n_pass, summary$overview$n_fail, 100 * summary$overview$pass_rate )) ``` ## HTML Check Report `ks_check_report_html()` produces a single self-contained `.html` file using `htmltools` and `reactable` — no Quarto, no Pandoc. The report contains KPI cards, a searchable violation summary, and a row-detail table with status badges. ```{r, message=FALSE} # Write to disk (auto-names file when path is NULL) ks_check_report_html( validation_result, path = "check-report.html", title = "ADSL validation", subtitle = "Study XYZ — run 2026-07-13", theme = "default" # or "slate" ) # Return an in-memory htmltools tag list for embedding report_tags <- ks_check_report_html(validation_result, path = NA) ``` The `path` argument follows the same three-way convention as `ks_report_html()`: `NULL` → auto-named file in the working directory, `NA` → in-memory tag list, or an explicit file path. ## XLSX Export `ks_save2xlsx_by()` writes one worksheet per unique value of a grouping column and applies Excel conditional formatting so rows are colour-highlighted based on the `check_msgs` content: ```{r, eval=FALSE} # Add a grouping column (e.g. by domain) and export validation_result |> dplyr::mutate(domain = "ADSL") |> ks_collapse_check_msgs(col = check_msgs) |> # XLSX needs a plain character column ks_save2xlsx_by( file = "adsl_checks.xlsx", colors = c( "OK" = "#CCFFCC", # green "must be" = "#FFCCCC", # red (substring match) "out of range" = "#FFEEAA", # amber "missing" = "#FFD0D0" # light red ), split_col = domain, msg_col = check_msgs ) ``` ![](excel_summary.png) To layer a `ks_compare_check_state()` status column on top — so *new* and *changed* rows are highlighted differently — pass both `status_col` and `status_colors`: ```{r, eval=FALSE} validation_result |> ks_collapse_check_msgs(col = check_msgs) |> ks_compare_check_state( previous = previous_run, key_cols = "USUBJID", compare_cols = "check_msgs" ) |> dplyr::mutate(domain = "ADSL") |> ks_save2xlsx_by( file = "adsl_checks_delta.xlsx", colors = c("OK" = "#CCFFCC", "must be" = "#FFCCCC"), split_col = domain, msg_col = check_msgs, status_col = status, status_colors = c("new" = "#D0E8FF", "changed" = "#FFF3CC") ) ``` ## Tracking Changes Across Runs `ks_compare_check_state()` compares the current validation result against a saved snapshot and adds a `status` column: | status | meaning | |-------------|---------------------------------------------| | `"new"` | Subject key not present in the previous run | | `"changed"` | Key exists but comparison signature differs | | `""` | Row is identical to the previous run | ```{r} # Simulate a "previous" run previous_run <- ks_check_rules(adsl, rules, mode = "all") |> ks_collapse_check_msgs(col = check_msgs) # Current run after data update current_run <- adsl |> dplyr::mutate(AGE = replace(AGE, USUBJID == "01-003", 22L)) |> # fix a negative age ks_check_rules(rules, mode = "all") |> ks_collapse_check_msgs(col = check_msgs) # Compare the two runs delta <- ks_compare_check_state( current = current_run, previous = previous_run, key_cols = "USUBJID", compare_cols = "check_msgs" ) delta[, c("USUBJID", "check_msgs", "status")] ``` This is especially useful in iterative QC workflows where you want to confirm that only the intended rows changed between data cuts. ## Visualizing Validation Results There are several possibilities to visualize a validation: ```{r} # For a single-row heatmap-like display, add a constant y value. visualize_results1 <- function(df) { df <- df %>% mutate( status = factor( ifelse( purrr::map_lgl(check_msgs, ~ "OK" %in% .x), "Valid", "Invalid" ), levels = c("Valid", "Invalid") ), y = "Subjects" ) ggplot(df, aes(x = USUBJID, y = y, fill = status)) + geom_tile(height = 0.6, color = "white") + scale_fill_manual( values = c( Valid = "#2ecc71", Invalid = "#e74c3c" ) ) + labs(x = "Subject", y = NULL, fill = "Status") + theme_minimal() + theme( axis.text.x = element_text(angle = 45, hjust = 1) ) } # Alternative: Bar chart (usually more informative) visualize_results2 <- function(df) { df %>% mutate( status = ifelse( purrr::map_lgl(check_msgs, ~ "OK" %in% .x), "Valid", "Invalid" ) ) %>% ggplot(aes(status, fill = status)) + geom_bar() + scale_fill_manual( values = c( Valid = "#2ecc71", Invalid = "#e74c3c" ) ) + labs( x = NULL, y = "Number of records", fill = "Status" ) + theme_minimal() } # Alternative: Show number of violations per subject # If subjects can have multiple violations, this is often the most useful diagnostic plot: visualize_results3 <- function(df) { df %>% mutate( n_violations = purrr::map_int( check_msgs, ~ if ("OK" %in% .x) 0L else length(.x) ) ) %>% ggplot(aes(reorder(USUBJID, n_violations), n_violations)) + geom_col() + coord_flip() + labs( x = "Subject", y = "Number of violations" ) + theme_minimal() } # Example visualization visualize_results1(validation_result) visualize_results2(validation_result) visualize_results3(validation_result) ``` For a data validation engine, the last plot tends to be the most actionable because it immediately highlights the worst offending records. # Common Patterns and Recipes ## Range Checks ```{r, eval=FALSE} rules_range <- list( age_range = list( expr = ~ AGE < 18 | AGE > 120, msg = "Age must be between 18 and 120" ), weight_range = list( expr = ~ WEIGHT < 30 | WEIGHT > 250, msg = "Weight must be between 30 and 250 kg" ) ) ``` ## Presence Checks ```{r, eval=FALSE} rules_presence <- list( id_not_missing = list( expr = ~ is.na(USUBJID), msg = "Subject ID cannot be missing" ), date_complete = list( expr = ~ is.na(ENR_DATE) | ENR_DATE == "", msg = "Enrollment date must be specified" ) ) ``` ## Format Validation ```{r, eval=FALSE} rules_format <- list( date_format = list( expr = ~ !grepl("^\\d{4}-\\d{2}-\\d{2}$", ENR_DATE), msg = "Date must be in YYYY-MM-DD format" ), email_format = list( expr = ~ !grepl("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$", EMAIL), msg = "Invalid email format" ) ) ``` ## Conditional Checks ```{r, eval=FALSE} rules_conditional <- list( # If treatment is "Placebo", then dose must be zero placebo_zero_dose = list( expr = ~ TRTP == "Placebo" & DOSE != 0, msg = "Placebo must have zero dose" ), # If value is missing, flag specific column aval_not_missing = list( expr = ~ is.na(AVAL), msg = "AVAL cannot be missing" ) ) ``` ## Reference Data Checks ```{r, eval=FALSE} # Predefined reference values reference_values <- tibble( code = c("M", "F", "U"), description = c("Male", "Female", "Unknown") ) rules_reference <- list( sex_known_values = list( expr = ~ !is.na(SEX) & !(SEX %in% reference_values$code), msg = glue("Invalid SEX code. Must be one of: {paste(reference_values$code, collapse = ", ")}") ) ) ``` # Group-Specific Validation ```{r, eval=FALSE} # Define group-specific rules group_rules <- list( control_group = list( list(expr = ~ TRTP == "Placebo" & DOSE != 0, msg = "Control group must receive zero dose"), list(expr = ~ TRTP == "Placebo" & !is.na(TRTA), msg = "Control group TRTA should be empty") ), treatment_group = list( list(expr = ~ TRTP == "Drug A" & DOSE < 10, msg = "Treatment group requires dose ≥ 10"), list(expr = ~ TRTP == "Drug A" & DOSE > 200, msg = "Treatment group dose too high (>200)") ), # Default rules for all groups common_rules = list( list(expr = ~ is.na(USUBJID), msg = "Subject ID required for all subjects"), list(expr = ~ nchar(TRTP) < 1, msg = "Treatment name must be specified") ) ) # Apply group-specific validation validation_result <- clinical_data |> mutate(group = case_when( TRTP == "Placebo" ~ "control", TRTP %in% c("Drug A", "Drug B") ~ "treatment", TRUE ~ "other" )) |> group_by(group) |> ks_check_rules( rules = group_rules[[cur_group()]], mode = "all" ) |> ungroup() ``` # Best Practices 1. **Modular Rule Definitions:** Organize rules into logical groups (entity-specific, business-specific, technical) 2. **Descriptive Error Messages:** Include enough context to help data stewards understand and fix issues 3. **Validation Layering:** Apply rule sets in logical sequences (format checks → range checks → business rules) 4. **Performance Considerations:** - Order performance-intensive rules first - Use "first" mode for critical validation paths - Consider caching rule compilation results 5. **Documentation:** Every rule should have clear documentation explaining: - What the rule checks - Why it matters - Expected data patterns 6. **Configuration Management:** Store rule parameters externally whenever possible for easier maintenance 7. **Graceful Degradation:** Allow partial validation when complete rule sets aren't available 8. **Integration Testing:** Always validate with realistic test data that includes boundary cases # Conclusion The `ks_check_rules()` framework represents a robust and flexible approach to data validation. By providing granular control over validation rules, performance-optimized execution, and clear error reporting, it enables data teams to build reliable validation pipelines. ## Key Benefits **Modular Architecture** - Rules are decoupled from business logic - Easy to extend and maintain - Supports dynamic rule configuration - Early-exit "first" mode for critical validation **Seamless tidyverse Integration** - Supports custom rule definitions - Integrates naturally with data processing pipelines - Works with standard tidyverse verbs (`mutate()`, `filter()`, etc.) **What's Available Now** | Function | What it does | |------------------------------------|------------------------------------| | `ks_check_rules()` | Apply validation rules row-wise (modes: `"first"` / `"all"`) | | `ks_collapse_check_msgs()` | Flatten list-column to plain character | | `ks_check_summary()` | Programmatic overview + per-message violation breakdown | | `ks_check_report_html()` | Self-contained HTML report (htmltools + reactable) | | `ks_compare_check_state()` | Diff two runs — tag rows as new / changed / unchanged | | `ks_save2xlsx_by()` | Split-by-group XLSX with conditional row colouring | **Future Roadmap** - Rule metadata annotations (severity levels, regulation references) - Cross-dataset relational checks (e.g. validate ADAE against ADSL keys) - Performance optimizations for datasets \> 1 M rows # Final Thoughts This validation framework represents more than just error checking—it's a strategic data governance mechanism. By investing in thoughtful validation patterns today, organizations can prevent downstream data issues, improve data quality, and reduce long-term maintenance costs. The modular design allows for incremental adoption: start with basic schema validation, then progressively add business rules, cross-field checks, and custom validation patterns. With comprehensive documentation and practical examples, `ks_check_rules` aims to become an essential tool for serious data practitioners.