Skip to contents

Introduction

{simaerep} calculates site-specific event reporting probabilities based on bootstrap simulations that replace individual patients with other patients from the same study that have at least the same number of visits. This makes it ideal for monitoring event reporting of ongoing trials in which not all sites and patients are starting in the trial at the same time and in which the event rates are not constant over the entire treatment cycle.

The statistical performance of {simaerep} is thus best measured against a data set that includes ongoing studies in different stages and non-constant event rates. For this we use a snapshot of our portfolio data to simulate a test data set from which we subsequently remove or add a specific percentage of events to create over- and under-reporting scenarios. We will then measure statistical performance of {simaerep} and compare it to those of other statistical methods.

We have previously evaluated {simaerep} for detecting adverse events under-reporting in a joint industry publication using a similar approach.

Koneswarakantha, B., Adyanthaya, R., Emerson, J. et al. An Open-Source R Package for Detection of Adverse Events Under-Reporting in Clinical Trials: Implementation and Validation by the IMPALA (Inter coMPany quALity Analytics) Consortium. Ther Innov Regul Sci 58, 591–599 (2024). https://doi.org/10.1007/s43441-024-00631-8

Test Data

Portfolio Configuration and Event Rates

The portfolio configuration will be used to generate compliant test data that is similar to a realistic portfolio of studies in which all sites are compliant. We will subsequently remove a percentage of AEs from each study site and calculate AE under-reporting statistics to calculate overall detection thresholds. The portfolio configuration should give a minimal description of the portfolio without violating data privacy laws or competitive intellectual property. We propose to include the following metrics into the portfolio configuration:

site parameters:

  • mean of all maximum patient visits
  • sd of of all maximum patient visits
  • total number patients

study parameters:

  • mean AE per visit

The information contained in a portfolio configuration is very scarce and thus can be shared more easily within the industry. We can use those parameters to simulate test data for assessing {simaerep} performance on a given portfolio.

We start with our standard input data set df_visit from which we extract the portfolio configuration and the event rates using simaerep::get_portf_config and simaerep::get_portf_event_rates:

This workflow will automatically:

  • remove patients with 0 visits
  • minimum number of patients per study
  • minimum number of sites per study
  • anonymize study and site IDs
df_visit1 <- sim_test_data_study(
  n_pat = 100,
  n_sites = 10,
  ratio_out = 0.4,
  factor_event_rate = - 0.6,
  event_rates = c(0.3, 1, 0.7, 0.1),
  study_id = "A"
)

df_visit2 <- sim_test_data_study(
  n_pat = 100,
  n_sites = 10,
  ratio_out = 0.2,
  factor_event_rate = - 0.1,
  event_rates = c(1, 0.5, 0.3, 0.3),
  study_id = "B"
)

df_visit <- bind_rows(df_visit1, df_visit2)


df_config <- simaerep::get_portf_config(
  df_visit,
  anonymize = TRUE,
  min_pat_per_study = 100,
  min_sites_per_study = 10
)

df_config %>%
  head(25) %>%
  knitr::kable()
study_id event_per_visit_mean site_id max_visit_sd max_visit_mean n_pat
0001 0.1517349 0001 4.175324 19.1 10
0001 0.1517349 0002 5.108816 18.1 10
0001 0.1517349 0003 3.011091 18.8 10
0001 0.1517349 0004 3.366502 18.0 10
0001 0.1517349 0005 3.831159 18.7 10
0001 0.1517349 0006 2.708013 19.0 10
0001 0.1517349 0007 1.885618 20.0 10
0001 0.1517349 0008 3.333333 20.0 10
0001 0.1517349 0009 3.765339 22.2 10
0001 0.1517349 0010 2.898275 19.2 10
0002 0.3070175 0001 4.237400 17.8 10
0002 0.3070175 0002 3.622461 19.3 10
0002 0.3070175 0003 3.272783 18.6 10
0002 0.3070175 0004 4.648775 20.5 10
0002 0.3070175 0005 3.368152 20.3 10
0002 0.3070175 0006 4.083844 20.3 10
0002 0.3070175 0007 4.900113 19.3 10
0002 0.3070175 0008 3.622461 20.3 10
0002 0.3070175 0009 3.604010 17.9 10
0002 0.3070175 0010 3.308239 19.5 10
df_event_rates <- simaerep::get_portf_event_rates(df_visit)

df_event_rates %>%
  filter(dense_rank(study_id) == 1) %>%
  knitr::kable()
study_id visit event_rate n_pat
0001 1 0.0000000 100
0001 2 0.8400000 100
0001 3 0.6100000 100
0001 4 0.1000000 100
0001 5 0.0700000 100
0001 6 0.0606061 99
0001 7 0.0707071 99
0001 8 0.0606061 99
0001 9 0.0909091 99
0001 10 0.0909091 99
0001 11 0.1111111 99
0001 12 0.0909091 99
0001 13 0.0927835 97
0001 14 0.0729167 96
0001 15 0.0659341 91
0001 16 0.0114943 87
0001 17 0.1250000 80
0001 18 0.0694444 72
0001 19 0.0476190 63
0001 20 0.1224490 49
0001 21 0.0500000 40
0001 22 0.0384615 26
0001 23 0.0666667 15
0001 24 0.0000000 12
0001 25 0.2857143 7
0001 26 0.0000000 3

The configuration and the event rates can be used to simulate a new set of portfolio data with simaerep::sim_test_data_portfolio

simaerep::sim_test_data_portfolio(df_config, df_event_rates, progress = TRUE)
## # A tibble: 3,751 × 8
##    study_id event_per_visit_mean site_id max_visit_sd max_visit_mean patient_id
##    <chr>                   <dbl> <chr>          <dbl>          <dbl> <chr>     
##  1 0001                    0.152 0001            4.18           19.1 0001      
##  2 0001                    0.152 0001            4.18           19.1 0001      
##  3 0001                    0.152 0001            4.18           19.1 0001      
##  4 0001                    0.152 0001            4.18           19.1 0001      
##  5 0001                    0.152 0001            4.18           19.1 0001      
##  6 0001                    0.152 0001            4.18           19.1 0001      
##  7 0001                    0.152 0001            4.18           19.1 0001      
##  8 0001                    0.152 0001            4.18           19.1 0001      
##  9 0001                    0.152 0001            4.18           19.1 0001      
## 10 0001                    0.152 0001            4.18           19.1 0001      
## # ℹ 3,741 more rows
## # ℹ 2 more variables: visit <int>, n_event <dbl>

Load Portfolio

To simulate a realistic test set with ongoing studies we load snapshots from our portfolio. We can use future package to set the number of workers for parallel processing.

df_config <- readr::read_csv("ae_conf_20240220.csv") %>%
  rename_with(~stringr::str_replace(., "ae_", "event_")) %>%
  rename(site_id = site_number)

df_event_rates <- readr::read_csv("ae_rates_20240220.csv") %>%
  rename_with(~stringr::str_replace(., "ae_", "event_")) %>%
  rename(visit = cum_visit)


suppressPackageStartupMessages(library(furrr))

plan(multisession, workers = 6)

df_portf <- sim_test_data_portfolio(
  df_config,
  df_event_rates,
  progress = TRUE,
  parallel = TRUE
)

plan(sequential)

Simulating Reporting Outlier

Next we will write a function that uses simaerep and other statistical methods to detect reporting outlier. We will apply the following.

  • remove or add events directly from the data set and not from an aggregated metric using simaerep::sim_out()
  • set threshold for confusion matrix so that all methods have similar fpr
  • test the following {simaerep} parameters:
    • classic algorithm
    • inframe algorithm with visit_med75
    • inframe algorithm
    • inframe algorithm w/o multiplicity correction
  • test the following outlier detection methods

Functions

funnel <- function(df) {
  
  df %>%
    filter(visit == max(visit), .by = "patient_id") %>%
    summarise(
      Metric = sum(.data$n_event) / sum(.data$visit),
      n_event = sum(n_event),
      visit = sum(visit),
      .by = "site_id"
    ) %>%
    mutate(
          vMu = sum(.data$n_event) / sum(.data$visit),
          z_0 = ifelse(.data$vMu == 0,
            0,
            (.data$Metric - .data$vMu) /
              sqrt(.data$vMu / .data$visit)
          ),
          phi = mean(.data$z_0^2),
          z_i = ifelse(.data$vMu == 0 | .data$phi == 0,
            0,
            (.data$Metric - .data$vMu) /
              sqrt(.data$phi * .data$vMu / .data$visit)
          )
    )
}

box <- function(df) {

  df <- df %>%
    filter(visit == max(visit), .by = "patient_id") %>%
    summarise(
      event_per_visit = sum(.data$n_event) / sum(.data$visit),
      .by = "site_id"
    )
  
  bx <- boxplot.stats(df$event_per_visit)

  df <- df %>%
    mutate(
      box_out = event_per_visit < bx$stats[1] | event_per_visit > bx$stats[5]
    )
  
}


perf <- function(df_visit, study_id, site_id, factor_event) {

  df_vs_study <- df_visit %>%
    simaerep::sim_out(study_id, site_id, factor_event)
  
  remove(df_visit)
  
  df_classic <- df_vs_study %>%
    simaerep(inframe = FALSE, progress = FALSE, check = FALSE) %>%
    .$df_eval %>%
    filter(.data$site_id == .env$site_id)

  df_inframe <- df_vs_study %>%
    simaerep(check = FALSE, progress = FALSE) %>%
    .$df_eval %>%
    filter(.data$site_id == .env$site_id)
  
  df_inframe_visit_med75 <- df_vs_study %>%
    simaerep(visit_med75 = TRUE, check = FALSE, progress = FALSE) %>%
    .$df_eval %>%
    filter(.data$site_id == .env$site_id)

  funnel_zi <- funnel(df_vs_study) %>%
    filter(.data$site_id == .env$site_id) %>%
    pull(z_i)
  
  box_out <- box(df_vs_study) %>%
    filter(.data$site_id == .env$site_id) %>%
    pull(box_out)
  
  df <- tibble(
    score_classic = df_classic$prob,
    score_classic_no_mult = df_classic$prob_no_mult,
    score_inframe = df_inframe$event_prob,
    score_inframe_no_mult = df_inframe$event_prob_no_mult,
    score_inframe_visit_med75 = df_inframe_visit_med75$event_prob,
    score_inframe_visit_med75_no_mult = df_inframe_visit_med75$event_prob_no_mult,
    score_funnel_zi = funnel_zi,
    score_box_out = as.integer(box_out),
    stat_classic_visit_med75 = df_classic$visit_med75,
    stat_classic_n_pat_with_med75 = df_classic$n_pat_with_med75,
    stat_classic_mean_event_site_med75 = df_classic$mean_event_site_med75,
    stat_classic_mean_event_study_med75 = df_classic$mean_event_study_med75,
    stat_inframe_visit_med75 = df_inframe_visit_med75$visit_med75,
    stat_inframe_visit_med75_n_pat_with_med75 = df_inframe_visit_med75$n_pat_with_med75,
    stat_inframe_visit_med75_events_per_visit_site = df_inframe_visit_med75$event_per_visit_site,
    stat_inframe_visit_med75_events_per_visit_study = df_inframe_visit_med75$event_per_visit_site,
    stat_inframe_n_pat = df_inframe$n_pat,
    stat_inframe_events_per_visit_site = df_inframe$event_per_visit_site,
    stat_inframe_events_per_visit_study = df_inframe$event_per_visit_study,
  )
  
  if (! any(str_detect(colnames(df), "no_mult"))) {
    stop("no scores w/o multiplicity correction available")
  }
    
  return(df)
}

# 50% over-reporting
perf(df_portf, study_id = "0010", site_id = "15153", factor_event = 0.5) %>% unlist()
##                                   score_classic 
##                                       0.8045000 
##                           score_classic_no_mult 
##                                       0.9970000 
##                                   score_inframe 
##                                       0.8289375 
##                           score_inframe_no_mult 
##                                       0.9860000 
##                       score_inframe_visit_med75 
##                                       0.8390000 
##               score_inframe_visit_med75_no_mult 
##                                       0.9940000 
##                                 score_funnel_zi 
##                                       2.1931127 
##                                   score_box_out 
##                                       0.0000000 
##                    stat_classic_visit_med75.80% 
##                                       7.0000000 
##                   stat_classic_n_pat_with_med75 
##                                       5.0000000 
##              stat_classic_mean_event_site_med75 
##                                       3.9000000 
##             stat_classic_mean_event_study_med75 
##                                       2.0576324 
##                    stat_inframe_visit_med75.80% 
##                                       7.0000000 
##       stat_inframe_visit_med75_n_pat_with_med75 
##                                       5.0000000 
##  stat_inframe_visit_med75_events_per_visit_site 
##                                       0.5571429 
## stat_inframe_visit_med75_events_per_visit_study 
##                                       0.5571429 
##                              stat_inframe_n_pat 
##                                       9.0000000 
##              stat_inframe_events_per_visit_site 
##                                       0.4615385 
##             stat_inframe_events_per_visit_study 
##                                       0.2937692
# 50% under-reporting
perf(df_portf, study_id = "0010", site_id = "15153", factor_event = - 0.5) %>% unlist()
##                                   score_classic 
##                                     -0.03400000 
##                           score_classic_no_mult 
##                                     -0.99400000 
##                                   score_inframe 
##                                     -0.27550000 
##                           score_inframe_no_mult 
##                                     -0.98000000 
##                       score_inframe_visit_med75 
##                                     -0.83900000 
##               score_inframe_visit_med75_no_mult 
##                                     -0.99900000 
##                                 score_funnel_zi 
##                                     -2.24765772 
##                                   score_box_out 
##                                      0.00000000 
##                    stat_classic_visit_med75.80% 
##                                      7.00000000 
##                   stat_classic_n_pat_with_med75 
##                                      5.00000000 
##              stat_classic_mean_event_site_med75 
##                                      0.50000000 
##             stat_classic_mean_event_study_med75 
##                                      2.05763240 
##                    stat_inframe_visit_med75.80% 
##                                      7.00000000 
##       stat_inframe_visit_med75_n_pat_with_med75 
##                                      5.00000000 
##  stat_inframe_visit_med75_events_per_visit_site 
##                                      0.07142857 
## stat_inframe_visit_med75_events_per_visit_study 
##                                      0.07142857 
##                              stat_inframe_n_pat 
##                                      9.00000000 
##              stat_inframe_events_per_visit_site 
##                                      0.15384615 
##             stat_inframe_events_per_visit_study 
##                                      0.29052308

Grid

df_grid <- df_portf %>%
  distinct(study_id, site_id) %>%
  # to reduce calculation time we only take every xth study
  filter(dense_rank(study_id)%%5 == 0) %>%
  mutate(factor_event = list(c( -1, -.75, -.5, -.25, -.1, 0, 0.1, 0.25, 0.5, 0.75, 1))) %>%
  unnest(factor_event)

df_grid
## # A tibble: 49,720 × 3
##    study_id site_id factor_event
##    <chr>    <chr>          <dbl>
##  1 0005     4480           -1   
##  2 0005     4480           -0.75
##  3 0005     4480           -0.5 
##  4 0005     4480           -0.25
##  5 0005     4480           -0.1 
##  6 0005     4480            0   
##  7 0005     4480            0.1 
##  8 0005     4480            0.25
##  9 0005     4480            0.5 
## 10 0005     4480            0.75
## # ℹ 49,710 more rows

Apply

Again we use the furrr and the future package for parallel processing. simaerep::purrr_bar is a wrapper around furrr functions that enables the progressr package to display a progress bar.

plan(multisession, workers = 6)

progressr::with_progress(
  df_perf <- df_grid %>%
    mutate(
      perf = simaerep::purrr_bar(
        list(study_id, site_id, factor_event),
        .purrr = furrr::future_pmap,
        .f = function(x, y, z) perf(df_portf, x, y, z),
        .purrr_args = list(.options = furrr_options(seed = TRUE)),
        .steps = nrow(.)
      )
    )
)

plan(sequential)


df_perf %>%
  unnest(perf) %>%
  readr::write_csv("perf.csv")
df_perf <- readr::read_csv("perf.csv", show_col_types = FALSE)
df_perf_long <- df_perf %>%
  pivot_longer(cols = - c(study_id, site_id, factor_event), names_to = "type", values_to = "score") %>%
  filter(startsWith(type, "score_")) %>%
  mutate(
    type = stringr::str_replace(type, "score_", ""),
    # we use the same cut-off for over- and under-reporting
    score = abs(score)
  )

Evaluation

Thresholds

We set the thresholds so that we get a fpr of 0.01.

Note that this results in probability thresholds ~ 0.99 for scores w/o multiplicity correction and in the recommended funnel plot score threshold of -2.

target_fpr <- 0.01

df_thresh <- df_perf_long %>%
  group_by(type) %>%
  nest() %>%
  ungroup() %>%
  mutate(
    data = map(data, ~ filter(., factor_event == 0)),
    thresh = map_dbl(data, ~ quantile(pull(., score), 1 - target_fpr)),
  ) %>%
  select(type, thresh)
  
df_thresh
## # A tibble: 8 × 2
##   type                        thresh
##   <chr>                        <dbl>
## 1 classic                      0.849
## 2 classic_no_mult              0.995
## 3 inframe                      0.849
## 4 inframe_no_mult              0.996
## 5 inframe_visit_med75          0.848
## 6 inframe_visit_med75_no_mult  0.996
## 7 funnel_zi                    2.70 
## 8 box_out                      1

Aggregate

get_prop_test_ci95 <- function(..., ix) {
  
  stopifnot(ix %in% c(1, 2))
  
  tryCatch(
    prop.test(...)$conf.int[ix],
    error = function(cnd) c(NA, NA)[ix]
  )
}

df_aggr <- df_perf_long %>%
  left_join(df_thresh, by = "type") %>%
  mutate(
    is_out = score >= thresh,
    is_out = ifelse(type == "box_out", score == 1, is_out)
  ) %>%
  summarise(
    n = n(),
    .by = c(type, factor_event, is_out)
  ) %>%
  pivot_wider(
    names_from = is_out,
    values_from = n,
    names_prefix = "is_out_",
    values_fill = 0
  ) %>%
  mutate(
    n_sites = is_out_TRUE + is_out_FALSE,
    ratio = is_out_TRUE / n_sites,
    ratio_type = ifelse(factor_event == 0, "fpr", "tpr"),
    ci95_low = map2_dbl(is_out_TRUE, n_sites, ~ get_prop_test_ci95(.x, .y, ix = 1)),
    ci95_high = map2_dbl(is_out_TRUE, n_sites, ~ get_prop_test_ci95(.x, .y, ix = 2)),
    type_strip = str_replace(type, "_no_mult", ""),
    has_mult = ! str_detect(type, "no_mult") & ! type %in% c("funnel_zi", "box_out")
  )

Table

Methods:

  • classic: classic algorithm
  • inframe: new algorithm using table operations
  • inframe w/o multiplicity correction: new algorithm using table operations without multiplicity corrections
  • inframe visit_med75: new algorithm using table operations and visit_med75
  • funnel_zi: funnel plot derived outlier detection
  • box_out: box plot derived outlier detection

FN: false negatives TP: true positives

df_aggr %>%
  select(method = type_strip, has_mult, factor_event, FN = is_out_FALSE, TP = is_out_TRUE, n_sites, ratio_type, ratio, ci95_low, ci95_high) %>%
  knitr::kable(digits = 4)
method has_mult factor_event FN TP n_sites ratio_type ratio ci95_low ci95_high
classic TRUE -1.00 1432 3088 4520 tpr 0.6832 0.6694 0.6967
classic FALSE -1.00 958 3562 4520 tpr 0.7881 0.7758 0.7998
inframe TRUE -1.00 1384 3136 4520 tpr 0.6938 0.6801 0.7072
inframe FALSE -1.00 1054 3466 4520 tpr 0.7668 0.7542 0.7790
inframe_visit_med75 TRUE -1.00 1556 2964 4520 tpr 0.6558 0.6417 0.6696
inframe_visit_med75 FALSE -1.00 1155 3365 4520 tpr 0.7445 0.7314 0.7571
funnel_zi FALSE -1.00 1643 2877 4520 tpr 0.6365 0.6223 0.6505
box_out FALSE -1.00 776 3744 4520 tpr 0.8283 0.8169 0.8391
classic TRUE -0.75 1772 2748 4520 tpr 0.6080 0.5935 0.6222
classic FALSE -0.75 1295 3225 4520 tpr 0.7135 0.7000 0.7266
inframe TRUE -0.75 1789 2731 4520 tpr 0.6042 0.5898 0.6185
inframe FALSE -0.75 1441 3079 4520 tpr 0.6812 0.6673 0.6947
inframe_visit_med75 TRUE -0.75 1832 2688 4520 tpr 0.5947 0.5802 0.6090
inframe_visit_med75 FALSE -0.75 1460 3060 4520 tpr 0.6770 0.6631 0.6906
funnel_zi FALSE -0.75 2423 2097 4520 tpr 0.4639 0.4493 0.4786
box_out FALSE -0.75 2337 2183 4520 tpr 0.4830 0.4683 0.4977
classic TRUE -0.50 2634 1886 4520 tpr 0.4173 0.4028 0.4318
classic FALSE -0.50 2250 2270 4520 tpr 0.5022 0.4875 0.5169
inframe TRUE -0.50 2718 1802 4520 tpr 0.3987 0.3844 0.4131
inframe FALSE -0.50 2395 2125 4520 tpr 0.4701 0.4555 0.4848
inframe_visit_med75 TRUE -0.50 2637 1883 4520 tpr 0.4166 0.4022 0.4311
inframe_visit_med75 FALSE -0.50 2335 2185 4520 tpr 0.4834 0.4687 0.4981
funnel_zi FALSE -0.50 3411 1109 4520 tpr 0.2454 0.2329 0.2582
box_out FALSE -0.50 3636 884 4520 tpr 0.1956 0.1842 0.2075
classic TRUE -0.25 3883 637 4520 tpr 0.1409 0.1310 0.1515
classic FALSE -0.25 3579 941 4520 tpr 0.2082 0.1965 0.2204
inframe TRUE -0.25 3917 603 4520 tpr 0.1334 0.1237 0.1437
inframe FALSE -0.25 3665 855 4520 tpr 0.1892 0.1779 0.2010
inframe_visit_med75 TRUE -0.25 3886 634 4520 tpr 0.1403 0.1303 0.1508
inframe_visit_med75 FALSE -0.25 3656 864 4520 tpr 0.1912 0.1798 0.2030
funnel_zi FALSE -0.25 4309 211 4520 tpr 0.0467 0.0408 0.0533
box_out FALSE -0.25 4307 213 4520 tpr 0.0471 0.0412 0.0538
classic TRUE -0.10 4444 76 4520 tpr 0.0168 0.0134 0.0211
classic FALSE -0.10 4367 153 4520 tpr 0.0338 0.0289 0.0396
inframe TRUE -0.10 4436 84 4520 tpr 0.0186 0.0149 0.0231
inframe FALSE -0.10 4393 127 4520 tpr 0.0281 0.0236 0.0334
inframe_visit_med75 TRUE -0.10 4454 66 4520 tpr 0.0146 0.0114 0.0187
inframe_visit_med75 FALSE -0.10 4402 118 4520 tpr 0.0261 0.0217 0.0313
funnel_zi FALSE -0.10 4469 51 4520 tpr 0.0113 0.0085 0.0149
box_out FALSE -0.10 4323 197 4520 tpr 0.0436 0.0379 0.0501
classic TRUE 0.00 4474 46 4520 fpr 0.0102 0.0075 0.0137
classic FALSE 0.00 4474 46 4520 fpr 0.0102 0.0075 0.0137
inframe TRUE 0.00 4473 47 4520 fpr 0.0104 0.0077 0.0139
inframe FALSE 0.00 4472 48 4520 fpr 0.0106 0.0079 0.0142
inframe_visit_med75 TRUE 0.00 4474 46 4520 fpr 0.0102 0.0075 0.0137
inframe_visit_med75 FALSE 0.00 4472 48 4520 fpr 0.0106 0.0079 0.0142
funnel_zi FALSE 0.00 4474 46 4520 fpr 0.0102 0.0075 0.0137
box_out FALSE 0.00 4253 267 4520 fpr 0.0591 0.0525 0.0664
classic TRUE 0.10 4399 121 4520 tpr 0.0268 0.0223 0.0320
classic FALSE 0.10 4318 202 4520 tpr 0.0447 0.0389 0.0512
inframe TRUE 0.10 4392 128 4520 tpr 0.0283 0.0238 0.0337
inframe FALSE 0.10 4330 190 4520 tpr 0.0420 0.0365 0.0484
inframe_visit_med75 TRUE 0.10 4389 131 4520 tpr 0.0290 0.0244 0.0344
inframe_visit_med75 FALSE 0.10 4342 178 4520 tpr 0.0394 0.0340 0.0456
funnel_zi FALSE 0.10 4401 119 4520 tpr 0.0263 0.0219 0.0315
box_out FALSE 0.10 4161 359 4520 tpr 0.0794 0.0718 0.0878
classic TRUE 0.25 3855 665 4520 tpr 0.1471 0.1370 0.1579
classic FALSE 0.25 3551 969 4520 tpr 0.2144 0.2026 0.2267
inframe TRUE 0.25 3870 650 4520 tpr 0.1438 0.1338 0.1545
inframe FALSE 0.25 3654 866 4520 tpr 0.1916 0.1803 0.2034
inframe_visit_med75 TRUE 0.25 3844 676 4520 tpr 0.1496 0.1394 0.1604
inframe_visit_med75 FALSE 0.25 3591 929 4520 tpr 0.2055 0.1939 0.2177
funnel_zi FALSE 0.25 4031 489 4520 tpr 0.1082 0.0994 0.1177
box_out FALSE 0.25 3855 665 4520 tpr 0.1471 0.1370 0.1579
classic TRUE 0.50 2662 1858 4520 tpr 0.4111 0.3967 0.4256
classic FALSE 0.50 2327 2193 4520 tpr 0.4852 0.4705 0.4999
inframe TRUE 0.50 2722 1798 4520 tpr 0.3978 0.3835 0.4122
inframe FALSE 0.50 2463 2057 4520 tpr 0.4551 0.4405 0.4697
inframe_visit_med75 TRUE 0.50 2677 1843 4520 tpr 0.4077 0.3934 0.4223
inframe_visit_med75 FALSE 0.50 2370 2150 4520 tpr 0.4757 0.4610 0.4903
funnel_zi FALSE 0.50 3020 1500 4520 tpr 0.3319 0.3182 0.3458
box_out FALSE 0.50 3021 1499 4520 tpr 0.3316 0.3180 0.3456
classic TRUE 0.75 1920 2600 4520 tpr 0.5752 0.5606 0.5897
classic FALSE 0.75 1623 2897 4520 tpr 0.6409 0.6267 0.6549
inframe TRUE 0.75 1978 2542 4520 tpr 0.5624 0.5478 0.5769
inframe FALSE 0.75 1757 2763 4520 tpr 0.6113 0.5969 0.6255
inframe_visit_med75 TRUE 0.75 1915 2605 4520 tpr 0.5763 0.5618 0.5908
inframe_visit_med75 FALSE 0.75 1682 2838 4520 tpr 0.6279 0.6136 0.6420
funnel_zi FALSE 0.75 2106 2414 4520 tpr 0.5341 0.5194 0.5487
box_out FALSE 0.75 2136 2384 4520 tpr 0.5274 0.5128 0.5421
classic TRUE 1.00 1394 3126 4520 tpr 0.6916 0.6779 0.7050
classic FALSE 1.00 1146 3374 4520 tpr 0.7465 0.7335 0.7590
inframe TRUE 1.00 1461 3059 4520 tpr 0.6768 0.6629 0.6904
inframe FALSE 1.00 1275 3245 4520 tpr 0.7179 0.7045 0.7310
inframe_visit_med75 TRUE 1.00 1403 3117 4520 tpr 0.6896 0.6758 0.7030
inframe_visit_med75 FALSE 1.00 1200 3320 4520 tpr 0.7345 0.7213 0.7473
funnel_zi FALSE 1.00 1519 3001 4520 tpr 0.6639 0.6499 0.6777
box_out FALSE 1.00 1490 3030 4520 tpr 0.6704 0.6564 0.6840

Plot

plot_perf <- function(df) {
  p <- df %>%
    mutate(
      factor_event = paste0("outlier reporting rate: ",  factor_event, " - ", ratio_type),
      factor_event = forcats::fct_relevel(factor_event, c("outlier reporting rate: 0 - fpr")),
      type = forcats::fct_relevel(type, c("box_out", "funnel_zi"))
    ) %>%
    group_by(factor_event) %>%
    ggplot(aes(type, ratio)) +
      geom_errorbar(aes(ymin = ci95_low, ymax = ci95_high, color = type_strip, alpha = has_mult), linewidth = 1) +
      facet_wrap(~ factor_event, ncol = 1) +
      coord_flip() +
      theme(
        legend.position = "right",
        axis.text.y = element_blank(),
        axis.ticks.y = element_blank()
      ) +
      labs(
        x = "",
        y = "Ratio (CI95)", 
        title = "{simaerep} Performance",
        color = "Method",
        alpha = "Multiplicity Correction"
      ) +
      scale_color_manual(values = rev(RColorBrewer::brewer.pal(n = 6, name = "Dark2"))) +
      scale_alpha_manual(values = c(1, 0.5))
  
  return(p)
}

p_over <- df_aggr %>%
  filter(factor_event >= 0) %>%
  plot_perf() +
  labs(title = "Over-Reporting") + 
  theme(legend.position = "none")

p_under <- df_aggr %>%
  filter(factor_event <= 0) %>%
  plot_perf() +
  labs(title = "Under-Reporting")


library(patchwork)


p_over + p_under + patchwork::plot_annotation(title = "{simaerep} Performance")

Summary

  • Multiplicity correction imposes a penalty on the true positive rate

This observation was already made by the Boeringer Ingelheim Team during the evaluation of {simaerep}. We can now reproducibly confirm this. The unaltered probability score as returned by the bootstrap algorithm already provides very realistic under-reporting probabilities.

  • {simaerep} outperforms simpler methods such as funnel plot and box plot outlier detection.

These controls confirm previous observations that were made during the {simaerep} validation.

  • {simaerep} algorithm variants are performing more or less at the same level.

The inframe method can be calculated in a database backend and also calculated delta events, while the classic method is faster but requires the data to be processed in memory.