Chi-Square prop testing

Table of Contents

I’ve played with running the prop.test() chi-square tests across tibbles and I think I’ve finally got an efficient way of doing it.

Simple prop testing

First, let’s look at a simple example. I have survey data from a control/exposed lift study and I want to see if the lift for each question is significant. The results are in a named list with a series of tidy tibbles.

result_list[1:3]
## $`I fully trust`
## # A tibble: 2 × 5
##   matched_control_xmedia svy_q  proportion     n total
##   <chr>                  <chr>       <dbl> <dbl> <dbl>
## 1 control                Brand1      0.339   339   998
## 2 test                   Brand1      0.431  1029  2389
## 
## $`I can fully identify with`
## # A tibble: 2 × 5
##   matched_control_xmedia svy_q  proportion     n total
##   <chr>                  <chr>       <dbl> <dbl> <dbl>
## 1 control                Brand1      0.289   288   998
## 2 test                   Brand1      0.385   919  2389
## 
## $`I really like`
## # A tibble: 2 × 5
##   matched_control_xmedia svy_q  proportion     n total
##   <chr>                  <chr>       <dbl> <dbl> <dbl>
## 1 control                Brand1      0.388   387   998
## 2 test                   Brand1      0.479  1144  2389

Structured like this, I can easily just use pivot_wider() to spread the data and then run the prop.test() function. I’ll write a function to do that .

result_prop_test <- function(res) {
  tmpout <- res |> 
    tidyr::pivot_wider(
      names_from = !!rlang::sym(names(res)[1]), 
      values_from = c(proportion, n, total)
    ) |> 
    dplyr::filter(!is.na(proportion_control)) |> 
    dplyr::filter(!is.na(proportion_test)) |> 
    dplyr::rowwise() |> 
    dplyr::mutate(
      prop_test = list(prop.test(c(n_control, n_test), c(total_control, total_test))),
      p_value = prop_test$p.value,  # Extract the p-value
      statistic = prop_test$statistic # Extract the chi-squared statistic
    ) |> 
    dplyr::mutate(lift = round((proportion_test - proportion_control)*100), 
                  sig_level = dplyr::case_when(p_value <= .05 ~ .95,
                                 p_value <= .1 & p_value >.05 ~ .90,
                                 p_value < .2 & p_value > .1 ~ .80)) 
  return(tmpout)
}

Now if we take a single tibble from our list and run it through our function, we can see that the results are now in one line and the p_value and statistic of the prop.test are in their own separate columns. The full results of the prop.test are also in a list column.

res1 <- result_prop_test(result_list[[1]])
res1
## # A tibble: 1 × 12
## # Rowwise: 
##   svy_q  proportion_control proportion_test n_control n_test total_control
##   <chr>               <dbl>           <dbl>     <dbl>  <dbl>         <dbl>
## 1 Brand1              0.339           0.431       339   1029           998
## # ℹ 6 more variables: total_test <dbl>, prop_test <list>, p_value <dbl>,
## #   statistic <dbl>, lift <dbl>, sig_level <dbl>

I can also just run the entire list through the function. It returns the same list format, but I can use data.table::rbindlist() to put into a tibble.

sig_results <- purrr::map(result_list, result_prop_test)

data.table::rbindlist(sig_results, idcol = "question") |> 
  as_tibble() |> 
  select(question, svy_q, contains("proportion"), lift, sig_level)
## # A tibble: 9 × 6
##   question              svy_q proportion_control proportion_test  lift sig_level
##   <chr>                 <chr>              <dbl>           <dbl> <dbl>     <dbl>
## 1 I fully trust         Bran…              0.339           0.431     9      0.95
## 2 I can fully identify… Bran…              0.289           0.385    10      0.95
## 3 I really like         Bran…              0.388           0.479     9      0.95
## 4 I would like to own   Bran…              0.395           0.487     9      0.95
## 5 I would be willing t… Bran…              0.262           0.383    12      0.95
## 6 Is leading in electr… Bran…              0.187           0.246     6      0.95
## 7 Is leading in digita… Bran…              0.297           0.388     9      0.95
## 8 Is leading in sustai… Bran…              0.231           0.319     9      0.95
## 9 Is a brand that will… Bran…              0.503           0.564     6      0.95

Prop testing across all options

Now for something a little more involved. In the same survey that gave us the control/exposed, we also have questions asked for the entire competitive set. The data is currently in a tidy frame that looks like this:

head(df, 10)
## # A tibble: 10 × 6
##    Category           svy_q  proportion     n total cat          
##    <fct>              <chr>       <dbl> <dbl> <dbl> <chr>        
##  1 Aided Awareness    Brand1      0.959  3427  3574 Brand Metrics
##  2 Aided Awareness    Brand2      0.931  3329  3574 Brand Metrics
##  3 Aided Awareness    Brand3      0.909  3250  3574 Brand Metrics
##  4 Aided Awareness    Brand4      0.935  3340  3574 Brand Metrics
##  5 Aided Awareness    Brand5      0.936  3345  3574 Brand Metrics
##  6 Aided Ad Awareness Brand1      0.45   1543  3574 Brand Metrics
##  7 Aided Ad Awareness Brand2      0.392  1306  3574 Brand Metrics
##  8 Aided Ad Awareness Brand3      0.428  1392  3574 Brand Metrics
##  9 Aided Ad Awareness Brand4      0.408  1364  3574 Brand Metrics
## 10 Aided Ad Awareness Brand5      0.354  1185  3574 Brand Metrics

I want to compare the results for each Category question for each brand against all others. So the function below will do that for me. It also gives each brand an id and those id’s are then used to determine which brands have significantly greater results compared to the rest.

proptest_dataframe <- function(df, significance_level = 0.1) {
  df <- df %>%
    dplyr::mutate(id = LETTERS[1:n()])  # Assign capital letters as IDs
  
  run_prop_test_significant <- function(i, j, df) {
    prop1 <- df$proportion[i]
    prop2 <- df$proportion[j]
    
    n1 <- df$n[i]
    n2 <- df$n[j]
    
    total1 <- df$total[i]
    total2 <- df$total[j]
    
    counts <- c(n1, n2)
    totals <- c(total1, total2)
    
    test_result <- prop.test(counts, totals)
    
    return(test_result$p.value < significance_level && prop1 > prop2)
  }
  
  df$greater_than <- NA
  
  for (i in 1:nrow(df)) {
    greater_ids <- c()
    for (j in 1:nrow(df)) {
      if (i != j && run_prop_test_significant(i, j, df)) {
        greater_ids <- c(greater_ids, df$id[j])
      }
    }
    df$greater_than[i] <- paste(greater_ids, collapse = ", ")
  }
  return(df)
}

proptest_dataframe(df[1:5,])
## # A tibble: 5 × 8
##   Category        svy_q  proportion     n total cat           id    greater_than
##   <fct>           <chr>       <dbl> <dbl> <dbl> <chr>         <chr> <chr>       
## 1 Aided Awareness Brand1      0.959  3427  3574 Brand Metrics A     "B, C, D, E"
## 2 Aided Awareness Brand2      0.931  3329  3574 Brand Metrics B     "C"         
## 3 Aided Awareness Brand3      0.909  3250  3574 Brand Metrics C     ""          
## 4 Aided Awareness Brand4      0.935  3340  3574 Brand Metrics D     "C"         
## 5 Aided Awareness Brand5      0.936  3345  3574 Brand Metrics E     "C"

So this works, but because my dataframe has multiple questions included, I can’t just pass the whole thing in. Every row will be treated as if it’s all part of one, big prop.test.

To fix this, I’m going to split my data by the Category question and then I’m going to run it through this function.

df_list <- split(df, df$Category)

results <- df_list |> 
    purrr::map(proptest_dataframe)

results
## $`Aided Ad Awareness`
## # A tibble: 5 × 8
##   Category           svy_q  proportion     n total cat        id    greater_than
##   <fct>              <chr>       <dbl> <dbl> <dbl> <chr>      <chr> <chr>       
## 1 Aided Ad Awareness Brand1      0.45   1543  3574 Brand Met… A     "B, C, D, E"
## 2 Aided Ad Awareness Brand2      0.392  1306  3574 Brand Met… B     "E"         
## 3 Aided Ad Awareness Brand3      0.428  1392  3574 Brand Met… C     "B, E"      
## 4 Aided Ad Awareness Brand4      0.408  1364  3574 Brand Met… D     "E"         
## 5 Aided Ad Awareness Brand5      0.354  1185  3574 Brand Met… E     ""          
## 
## $`Aided Awareness`
## # A tibble: 5 × 8
##   Category        svy_q  proportion     n total cat           id    greater_than
##   <fct>           <chr>       <dbl> <dbl> <dbl> <chr>         <chr> <chr>       
## 1 Aided Awareness Brand1      0.959  3427  3574 Brand Metrics A     "B, C, D, E"
## 2 Aided Awareness Brand2      0.931  3329  3574 Brand Metrics B     "C"         
## 3 Aided Awareness Brand3      0.909  3250  3574 Brand Metrics C     ""          
## 4 Aided Awareness Brand4      0.935  3340  3574 Brand Metrics D     "C"         
## 5 Aided Awareness Brand5      0.936  3345  3574 Brand Metrics E     "C"         
## 
## $`Brand Momentum - Top 2 Box`
## # A tibble: 5 × 8
##   Category                 svy_q proportion     n total cat   id    greater_than
##   <fct>                    <chr>      <dbl> <dbl> <dbl> <chr> <chr> <chr>       
## 1 Brand Momentum - Top 2 … Bran…      0.556  1905  3574 Bran… A     "D"         
## 2 Brand Momentum - Top 2 … Bran…      0.579  1926  3574 Bran… B     "C, D"      
## 3 Brand Momentum - Top 2 … Bran…      0.559  1817  3574 Bran… C     "A"         
## 4 Brand Momentum - Top 2 … Bran…      0.53   1770  3574 Bran… D     ""          
## 5 Brand Momentum - Top 2 … Bran…      0.594  1987  3574 Bran… E     "A, C, D"   
## 
## $`Purchase Consideration`
## # A tibble: 5 × 8
##   Category               svy_q  proportion     n total cat    id    greater_than
##   <fct>                  <chr>       <dbl> <dbl> <dbl> <chr>  <chr> <chr>       
## 1 Purchase Consideration Brand1      0.498  1706  3574 Brand… A     "D, E"      
## 2 Purchase Consideration Brand2      0.499  1662  3574 Brand… B     "D, E"      
## 3 Purchase Consideration Brand3      0.518  1682  3574 Brand… C     "D, E"      
## 4 Purchase Consideration Brand4      0.443  1480  3574 Brand… D     ""          
## 5 Purchase Consideration Brand5      0.434  1452  3574 Brand… E     ""

Again, everything works as it should. But I also want to present these results to someone, and right now, the results are in a list of tidy tibbles. I’m going to add to my current proptest_dataframe() function to widen the data and also to do the prep work necessary to put these results into a gt table.

proptest_dataframe <- function(df, significance_level = 0.1) {
  df <- df %>%
    dplyr::mutate(id = LETTERS[1:n()])  # Assign capital letters as IDs
  
  run_prop_test_significant <- function(i, j, df) {
    prop1 <- df$proportion[i]
    prop2 <- df$proportion[j]
    
    n1 <- df$n[i]
    n2 <- df$n[j]
    
    total1 <- df$total[i]
    total2 <- df$total[j]
    
    counts <- c(n1, n2)
    totals <- c(total1, total2)
    
    test_result <- prop.test(counts, totals)
    
    return(test_result$p.value < significance_level && prop1 > prop2)
  }
  
  df$greater_than <- NA
  
  for (i in 1:nrow(df)) {
    greater_ids <- c()
    for (j in 1:nrow(df)) {
      if (i != j && run_prop_test_significant(i, j, df)) {
        greater_ids <- c(greater_ids, df$id[j])
      }
    }
    df$greater_than[i] <- paste(greater_ids, collapse = ", ")
  }
  
  # here i'm going to subscript my `greater_than` to show significance
  df <- df |> 
    mutate(
      brand_id = glue("{svy_q} {id}"),  
      proportion_with_sub = ifelse(
        greater_than != "", 
        glue("{round(proportion * 100)}% <sub>({greater_than})</sub>"),  
        glue("{round(proportion * 100)}%")
      )
    )
  
  # widen the data
  df_wide <- df |> 
    dplyr::select(Category, cat, brand_id, proportion_with_sub) |> 
    tidyr::pivot_wider(
      names_from = brand_id, 
      values_from = proportion_with_sub,
      values_fn = list(proportion_with_sub = function(x) paste(x, collapse = ""))
    )
  
  return(df_wide)
}

df_list <- split(df, df$Category)

results <- df_list |> 
    purrr::map(proptest_dataframe)

results
## $`Aided Ad Awareness`
## # A tibble: 1 × 7
##   Category          cat   `Brand1 A` `Brand2 B` `Brand3 C` `Brand4 D` `Brand5 E`
##   <fct>             <chr> <chr>      <chr>      <chr>      <chr>      <chr>     
## 1 Aided Ad Awarene… Bran… 45% <sub>… 39% <sub>… 43% <sub>… 41% <sub>… 35%       
## 
## $`Aided Awareness`
## # A tibble: 1 × 7
##   Category        cat     `Brand1 A` `Brand2 B` `Brand3 C` `Brand4 D` `Brand5 E`
##   <fct>           <chr>   <chr>      <chr>      <chr>      <chr>      <chr>     
## 1 Aided Awareness Brand … 96% <sub>… 93% <sub>… 91%        94% <sub>… 94% <sub>…
## 
## $`Brand Momentum - Top 2 Box`
## # A tibble: 1 × 7
##   Category          cat   `Brand1 A` `Brand2 B` `Brand3 C` `Brand4 D` `Brand5 E`
##   <fct>             <chr> <chr>      <chr>      <chr>      <chr>      <chr>     
## 1 Brand Momentum -… Bran… 56% <sub>… 58% <sub>… 56% <sub>… 53%        59% <sub>…
## 
## $`Purchase Consideration`
## # A tibble: 1 × 7
##   Category          cat   `Brand1 A` `Brand2 B` `Brand3 C` `Brand4 D` `Brand5 E`
##   <fct>             <chr> <chr>      <chr>      <chr>      <chr>      <chr>     
## 1 Purchase Conside… Bran… 50% <sub>… 50% <sub>… 52% <sub>… 44%        43%

Note that the column names have a capital letter next to them; that way each brand is readily identifiable for the reader of the table. Now we’re going to bind our data into a single tibble, and then we’re going to pass it to gt to make a table.

combined_result <- results |> 
    data.table::rbindlist(fill = TRUE) |> 
    tibble::as_tibble() |> 
    dplyr::mutate(Category = as.character(Category))

library(gt)
  
  # Programmatically generate the labels for the columns (with <br>)
  col_labels <- combined_result |> 
    colnames() |> 
    set_names() |>   # Keeps the original column names as-is
    map(~ html(glue("{gsub(' ', '<br>', .)}")))  # Programmatically replace space with <br> in labels
  
  # Create the gt table with programmatically generated column labels and subscripting
  combined_result |> 
    group_by(cat) |> 
    gt() |> 
    tab_header(
      title = "Competitive summary",
      ) |> 
    cols_label(.list = col_labels) |> 
    fmt_markdown(columns = everything()) |> 
    # bolding groups and columns 
    tab_style(
      style = cell_text(weight = 'bold',
                        size = px(13)),
      locations = list(
        cells_column_labels(
          columns = everything()),
        cells_row_groups(groups = TRUE))
    ) |> 
    gt::tab_footnote(
      footnote = "A footnote goes here to explain the table",
      locations = gt::cells_title(groups = "title")
    ) |> 
    # aligning the title left
    gt::tab_style(
      style = gt::cell_text(align = 'left',
                            weight = 'bold',
                            size = gt::px(16)),
      locations = gt::cells_title(c("title"))
    ) |> 
    # aligning the subtitle left
    gt::tab_style(
      style = gt::cell_text(align = 'left',
                            weight = 'bold',
                            size = gt::px(14)),
      locations = gt::cells_title(c("subtitle")) 
    ) |> 
    # centering column labels (not first column)
    gt::tab_style(
      style = gt::cell_text(align = 'center'),
      locations = gt::cells_column_labels(
        columns = -1  # Exclude the first column
      )
    ) |>
    # font size in table
    gt::tab_style(
      style = gt::cell_text(size = px(12)),
      locations = cells_body(
        columns = dplyr::everything())
    ) |> 
    # centering all variables in table (not first column)
    gt::tab_style(
      style = gt::cell_text(align = "center"),
      locations = gt::cells_body(columns = -1)
    ) |> 
    # don't have Category column labeled
    cols_label(
      Category = ""
    ) |> 
    # final options
    gt::tab_options(
      data_row.padding = gt::px(4),
      row_group.padding = gt::px(4),
      source_notes.font.size = gt::px(10),
      footnotes.font.size = gt::px(10),
      footnotes.marks = "" # empty footnote mark
    ) 
Competitive summary
Brand1
A
Brand2
B
Brand3
C
Brand4
D
Brand5
E
Brand Metrics
Aided Ad Awareness 45% (B, C, D, E) 39% (E) 43% (B, E) 41% (E) 35%
Aided Awareness 96% (B, C, D, E) 93% (C) 91% 94% (C) 94% (C)
Brand Momentum - Top 2 Box 56% (D) 58% (C, D) 56% (A) 53% 59% (A, C, D)
Purchase Consideration 50% (D, E) 50% (D, E) 52% (D, E) 44% 43%
A footnote goes here to explain the table
Taylor Grant
Taylor Grant
Group Director, Strategy & Analytics
Previous

Related