The change-classification system

Design principles

Classification in trialdiff is deliberately boring and auditable:

There is no machine learning and no hidden scoring.

The built-in categories

library(trialdiff)
td_categories()
#> # A tibble: 16 × 2
#>    category                    label                         
#>    <chr>                       <chr>                         
#>  1 new_subject                 New subject                   
#>  2 subject_removed             Subject removed               
#>  3 new_visit                   New visit                     
#>  4 new_assessment              New assessment                
#>  5 new_record                  New record                    
#>  6 record_removed              Record removed                
#>  7 treatment_assignment_change Treatment-assignment change   
#>  8 derived_variable_change     Derived-variable change       
#>  9 corrected_value             Corrected value               
#> 10 missing_to_value            Missing to non-missing        
#> 11 value_to_missing            Non-missing to missing        
#> 12 variable_added              Variable added                
#> 13 variable_removed            Variable removed              
#> 14 type_change                 Type change                   
#> 15 label_change                Label change                  
#> 16 unclassified                Unclassified - requires review

The change register

diff <- compare_cut(adsl_cut1, adsl_cut2, by = "USUBJID", dataset = "ADSL")
reg <- as_register(diff)
reg[, c(".change_id", "record_type", "USUBJID", "variable", "old_value",
        "new_value", "change")]
#> # A tibble: 4 × 7
#>   .change_id record_type USUBJID     variable old_value new_value         change
#>   <chr>      <chr>       <chr>       <chr>    <chr>     <chr>             <chr> 
#> 1 CHG00001   added       01-718-1371 <NA>     <NA>      <NA>              recor…
#> 2 CHG00002   added       01-718-1427 <NA>     <NA>      <NA>              recor…
#> 3 CHG00003   modified    01-701-1015 TRT01P   Placebo   Xanomeline Low D… value 
#> 4 CHG00004   modified    01-701-1015 TRT01A   Placebo   Xanomeline Low D… value

Applying the default rules

classified <- classify_changes(diff)
classified$register[, c(".change_id", "category", "category_label", "reason")]
#> # A tibble: 4 × 4
#>   .change_id category                    category_label              reason     
#>   <chr>      <chr>                       <chr>                       <chr>      
#> 1 CHG00001   new_subject                 New subject                 Subject '0…
#> 2 CHG00002   new_subject                 New subject                 Subject '0…
#> 3 CHG00003   treatment_assignment_change Treatment-assignment change Treatment …
#> 4 CHG00004   treatment_assignment_change Treatment-assignment change Treatment …

Writing a custom rule

A rule is a predicate over the register. The context contains the dataset name, keys and the sets of subjects present in each cut.

age_rule <- td_rule(
  name = "age_change",
  label = "Age change",
  priority = 1L,
  test = function(register, context) {
    register$record_type == "modified" & register$variable == "AGE"
  },
  reason = function(register, context) {
    sprintf("Age changed for %s.", register$.subject)
  }
)

custom <- classify_changes(
  diff,
  rules = c(list(age_rule), td_default_rules())
)
custom$register$category[custom$register$variable == "AGE"]
#> [1] NA NA

Prioritisation

Rules are evaluated in ascending priority order. This matters when more than one rule could apply. For example, AVAL is a derived variable and a value can transition from missing to non-missing. Missingness transitions have a higher priority than the generic derived-variable rule, because they are more actionable for review:

old <- data.frame(USUBJID = c("S1", "S2"), AVAL = c(NA, 5))
new <- data.frame(USUBJID = c("S1", "S2"), AVAL = c(3, NA))
classified_missing <- classify_changes(compare_cut(old, new, by = "USUBJID",
                                                    dataset = "ADLB"))
classified_missing$modified[, c("USUBJID", "variable", "change", "category")]
#> # A tibble: 2 × 4
#>   USUBJID variable change           category        
#>   <chr>   <chr>    <chr>            <chr>           
#> 1 S1      AVAL     missing_to_value missing_to_value
#> 2 S2      AVAL     value_to_missing value_to_missing

Any change that no rule matches is marked unclassified and surfaced in the report as an item requiring manual review, so the rule set can never silently hide a change.