Getting Started with RulesTools

Introduction

Association rule mining (ARM) looks for patterns of the form “if A, then B” in a table of categorical data called a transactional database. Each rule {A} => {B} is described by three standard measures:

  • support: the proportion of observations that contain both A and B, which is a measure of joint probability;
  • confidence: the proportion of observations containing A that also contain B, which is a measure of conditional probability;
  • lift: confidence divided by the baseline frequency (i.e., support) of B. A lift above 1 means B occurs more often alongside A than it would by chance.

The arules package provides the mining engine (apriori(), eclat(), and so on). RulesTools supports the steps around it:

Step Function Purpose
Prepare dtize_col() Discretize one numeric vector into labelled categories
Prepare dtize_df() Discretize every numeric column of a data frame, with optional missing-value imputation
Analyze compare_rules() Find the rules shared by two or more rule sets
Visualize rule_euler() Euler diagram of overlapping rule sets
Visualize rule_heatmap() Antecedent-by-consequent heatmap of support, confidence or lift

This vignette walks through a complete workflow using the BrookTrout dataset that ships with the package.

Setup

install.packages("arules")
install.packages("RulesTools")
library(RulesTools)
library(arules)

The BrookTrout dataset

BrookTrout contains environmental DNA (eDNA) measurements and water quality readings collected at sites at Hanlon Creek, a conservation area in Guelph, ON., Canada. The data are adapted from Nolan et al. (2023), a study that compared eDNA sampling with electrofishing for detecting native brook trout (Salvelinus fontinalis). Aquatic eDNA sampling entails collecting water samples using commercial eDNA samplers, here, the Halltech OSMOS and the Smith Root “ANDe”.

data(BrookTrout)
str(BrookTrout)
#> 'data.frame':    126 obs. of  10 variables:
#>  $ Backpack       : chr  "OSMOS" "OSMOS" "OSMOS" "OSMOS" ...
#>  $ Site           : chr  "1" "1" "1" "1" ...
#>  $ eFishCatch     : int  0 0 0 0 0 0 0 0 0 0 ...
#>  $ AirTemp        : int  14 14 14 14 14 14 14 14 14 14 ...
#>  $ WaterTemp      : num  14.7 14.7 14.7 14.7 14.7 14.7 14.7 14.7 14.7 14.7 ...
#>  $ pH             : num  8.05 8.05 8.05 8.05 8.05 8.05 8.05 8.05 8.05 8.05 ...
#>  $ DissolvedOxygen: num  9.9 9.9 9.9 9.9 9.9 9.9 9.9 9.9 9.9 9.9 ...
#>  $ Conductivity   : int  1046 1046 1046 1046 1046 1046 1046 1046 1046 1046 ...
#>  $ VolumeFiltered : num  1.34 1.34 1.34 1.34 1.34 1.34 1.03 1.03 1.03 1.03 ...
#>  $ eDNAConc       : num  67.1 83.8 89.2 93.1 38.1 ...

Each row is one sample. The variables are:

Variable Type Description
Backpack character eDNA sampler type: "OSMOS" or "ANDe"
Site integer Site number
eFishCatch integer Number of fish caught by electrofishing
AirTemp numeric Air temperature (°C)
WaterTemp numeric Water temperature (°C)
pH numeric Water pH
DissolvedOxygen numeric Dissolved oxygen (mg/L)
Conductivity numeric Conductivity (µS/cm)
VolumeFiltered numeric Volume of water filtered (L)
eDNAConc numeric eDNA concentration (copies per µL)
summary(BrookTrout)
#>       Backpack          Site       eFishCatch        AirTemp     
#>  Length   :126   Length   :126   Min.   : 0.000   Min.   :14.00  
#>  N.unique :  2   N.unique :  5   1st Qu.: 1.000   1st Qu.:14.00  
#>  N.blank  :  0   N.blank  :  0   Median : 2.000   Median :14.00  
#>  Min.nchar:  4   Min.nchar:  1   Mean   : 2.952   Mean   :16.57  
#>  Max.nchar:  5   Max.nchar:  1   3rd Qu.: 2.000   3rd Qu.:20.00  
#>                                  Max.   :10.000   Max.   :20.00  
#>    WaterTemp           pH       DissolvedOxygen   Conductivity  VolumeFiltered
#>  Min.   :12.70   Min.   :7.74   Min.   : 8.070   Min.   : 950   Min.   :0.95  
#>  1st Qu.:14.70   1st Qu.:8.02   1st Qu.: 8.340   1st Qu.:1046   1st Qu.:1.01  
#>  Median :14.70   Median :8.04   Median : 9.900   Median :1047   Median :1.04  
#>  Mean   :14.67   Mean   :7.99   Mean   : 9.605   Mean   :1123   Mean   :1.11  
#>  3rd Qu.:15.40   3rd Qu.:8.05   3rd Qu.:10.540   3rd Qu.:1136   3rd Qu.:1.10  
#>  Max.   :15.60   Max.   :8.09   Max.   :10.940   Max.   :1375   Max.   :1.83  
#>     eDNAConc     
#>  Min.   :  0.00  
#>  1st Qu.:  0.00  
#>  Median : 25.23  
#>  Mean   : 34.76  
#>  3rd Qu.: 52.26  
#>  Max.   :240.92
colSums(is.na(BrookTrout))
#>        Backpack            Site      eFishCatch         AirTemp       WaterTemp 
#>               0               0               0               0               0 
#>              pH DissolvedOxygen    Conductivity  VolumeFiltered        eDNAConc 
#>               0               0               0               0               0
hist(
  BrookTrout$eDNAConc,
  main = "Distribution of eDNA concentration",
  xlab = "eDNA concentration (copies/μL)"
)

Our question for the rest of the tutorial is: which environmental conditions tend to occur together with high eDNA concentrations?

Preparing the data

apriori() works on categorical data, so the numeric measurements must be discretized first.

One column at a time: dtize_col()

dtize_col() turns a numeric vector into a factor. By default it splits at the median into "low" and "high".

pH_group <- dtize_col(BrookTrout$pH)
table(pH_group, useNA = "ifany")
#> pH_group
#>  low high 
#>   78   48

You can supply your own cutoff and labels. Here we split pH at the neutral pH point of 7. Intervals are closed on the right by default, so a value of exactly 7 falls in the lower group.

pH_category <- dtize_col(
  BrookTrout$pH,
  cutoff = 7,
  labels = c("acidic or neutral", "basic")
)
table(pH_category, useNA = "ifany")
#> pH_category
#> acidic or neutral             basic 
#>                 0               126

Several cutoffs give several categories, as long as you supply one more label than cutoffs. With infinity = TRUE (the default) the outer intervals extend to -Inf and Inf. Here we use the tertiles of water temperature:

tertiles <- quantile(BrookTrout$WaterTemp, probs = c(1/3, 2/3), na.rm = TRUE)

water_temp <- dtize_col(
  BrookTrout$WaterTemp,
  cutoff = unname(tertiles),
  labels = c("cool", "moderate", "warm")
)
table(water_temp, useNA = "ifany")
#> water_temp
#>     cool moderate     warm 
#>       72       24       30

dtize_col() can also fill in missing values before discretizing, with na_fill = "mean" or "median":

dtize_col(
  c(1, 2, NA, 4, 5),
  cutoff = "mean",
  include_right = FALSE,
  na_fill = "mean"
)
#> [1] low  low  high high high
#> Levels: low high

An entire data frame: dtize_df()

dtize_df() applies the same idea to every numeric column of a data frame and leaves non-numeric columns alone. Before using it we make two preparatory choices:

  • Site is an identifier that happens to be stored as an integer. Splitting it into “low” and “high” sites would be meaningless, so we drop it.
  • Backpack is stored as character, so we convert it to a factor, which arules handles directly.
bt <- subset(BrookTrout, select = -Site)
bt$Backpack <- factor(bt$Backpack)

The cutoff you choose can change which observations count as “low” and “high”, so it helps to compare the mean and median of each numeric column first. Here, we also include biological cutoffs based on guidelines from the literature and online sources (e.g., Environment and Climate Change Canada, as well as the Canadian Council of Ministers of the Environment (CCME)). For eDNAConc, a limit of detection (LOD) of 13.3 was found by Nolan et al. (2023).

num_cols <- names(bt)[vapply(bt, is.numeric, logical(1))]

bio_limits <- c(
  eFishCatch      = 0,
  AirTemp         = 15.03,
  WaterTemp       = 15.00,
  pH              = 7.75,
  DissolvedOxygen = 9.50,
  Conductivity    = 1047,
  VolumeFiltered  = 1.06,
  eDNAConc        = 13.30
)

summary_tbl <- data.frame(
  mean   = vapply(bt[num_cols], mean, numeric(1), na.rm = TRUE),
  median = vapply(bt[num_cols], median, numeric(1), na.rm = TRUE),
  bio    = bio_limits[num_cols]
)

round(summary_tbl, 2)
#>                    mean  median     bio
#> eFishCatch         2.95    2.00    0.00
#> AirTemp           16.57   14.00   15.03
#> WaterTemp         14.67   14.70   15.00
#> pH                 7.99    8.04    7.75
#> DissolvedOxygen    9.60    9.90    9.50
#> Conductivity    1123.38 1047.00 1047.00
#> VolumeFiltered     1.11    1.04    1.06
#> eDNAConc          34.76   25.23   13.30
bt_median <- dtize_df(bt, cutoff = "median")
bt_mean   <- dtize_df(bt, cutoff = "mean")

head(bt_median)
#>   Backpack eFishCatch AirTemp WaterTemp   pH DissolvedOxygen Conductivity
#> 1    OSMOS        low     low       low high             low          low
#> 2    OSMOS        low     low       low high             low          low
#> 3    OSMOS        low     low       low high             low          low
#> 4    OSMOS        low     low       low high             low          low
#> 5    OSMOS        low     low       low high             low          low
#> 6    OSMOS        low     low       low high             low          low
#>   VolumeFiltered eDNAConc
#> 1           high     high
#> 2           high     high
#> 3           high     high
#> 4           high     high
#> 5           high     high
#> 6           high     high

Custom labels and interval options work just as they do in dtize_col(). Setting include_right = FALSE makes intervals closed on the left instead, so a value exactly equal to the cutoff moves into the “high” group:

bt_median_left <- dtize_df(bt, cutoff = "median", include_right = FALSE)

# Custom labels
head(dtize_df(bt, cutoff = "median", labels = c("lower half", "upper half")), 3)
#>   Backpack eFishCatch    AirTemp  WaterTemp         pH DissolvedOxygen
#> 1    OSMOS lower half lower half lower half upper half      lower half
#> 2    OSMOS lower half lower half lower half upper half      lower half
#> 3    OSMOS lower half lower half lower half upper half      lower half
#>   Conductivity VolumeFiltered   eDNAConc
#> 1   lower half     upper half upper half
#> 2   lower half     upper half upper half
#> 3   lower half     upper half upper half

Missing values

Left alone (na_fill = "none", the default), missing values stay NA, and arules treats them as contributing no item for that variable. dtize_df() can instead impute them with "mean", "median", or "pmm" (predictive mean matching via the mice R package with default function arguments). For "pmm", you can set the number of imputations m, the iterations maxit, and a seed for reproducibility.

The BrookTrout data contains no missing values; however, the built-in airquality data does, so we use it to demonstrate:

colSums(is.na(airquality))
#>   Ozone Solar.R    Wind    Temp   Month     Day 
#>      37       7       0       0       0       0

air_disc <- dtize_df(
  airquality,
  cutoff = "mean",
  na_fill = "pmm",
  m = 5,
  maxit = 5,
  seed = 42
)

colSums(is.na(air_disc))
#>   Ozone Solar.R    Wind    Temp   Month     Day 
#>       0       0       0       0       0       0

Mining association rules

With discretized data in hand, apriori() does the mining. We restrict the right-hand side to eDNAConc=high so every rule describes conditions associated with high eDNA concentration. Setting default = "lhs" keeps all other items on the left-hand side. We also cap rules at 10 items (maxlen = 10) to keep the rule sets small and readable.

mine_rules <- function(data) {
  apriori(
    data,
    parameter  = list(supp = 1/126, conf = 1/126, maxlen = 10, target = "rules"),
    appearance = list(rhs = "eDNAConc=high", default = "lhs"),
    control    = list(verbose = FALSE)
  )
}

rules_median      <- mine_rules(bt_median)
rules_median_left <- mine_rules(bt_median_left)
rules_mean        <- mine_rules(bt_mean)

c(
  median       = length(rules_median),
  median_left  = length(rules_median_left),
  mean         = length(rules_mean)
)
#>      median median_left        mean 
#>        1552        1736        1424

We now have three rule sets that differ only in how the data were discretized. The first two analyses use the same median cutoff but assign observations exactly equal to the cutoff to different categories. This illustrates that even boundary conventions can affect mined rules. Look at the strongest rules from the first rule set:

inspect(head(sort(rules_median, by = "lift"), 10))
#>      lhs                        rhs                support confidence   coverage lift count
#> [1]  {eFishCatch=high,                                                                     
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.04761905          1 0.04761905    2     6
#> [2]  {DissolvedOxygen=high,                                                                
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.09523810          1 0.09523810    2    12
#> [3]  {WaterTemp=high,                                                                      
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.09523810          1 0.09523810    2    12
#> [4]  {AirTemp=high,                                                                        
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.09523810          1 0.09523810    2    12
#> [5]  {Conductivity=high,                                                                   
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.09523810          1 0.09523810    2    12
#> [6]  {eFishCatch=high,                                                                     
#>       pH=high,                                                                             
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.04761905          1 0.04761905    2     6
#> [7]  {eFishCatch=high,                                                                     
#>       DissolvedOxygen=high,                                                                
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.04761905          1 0.04761905    2     6
#> [8]  {eFishCatch=high,                                                                     
#>       WaterTemp=high,                                                                      
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.04761905          1 0.04761905    2     6
#> [9]  {eFishCatch=high,                                                                     
#>       AirTemp=high,                                                                        
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.04761905          1 0.04761905    2     6
#> [10] {eFishCatch=high,                                                                     
#>       Conductivity=high,                                                                   
#>       VolumeFiltered=high}   => {eDNAConc=high} 0.04761905          1 0.04761905    2     6

Comparing rule sets

Are the rules stable when we change the discretization? compare_rules() takes any number of named rule sets and finds the rules they have in common.

shared <- compare_rules(
  median_right = rules_median,
  median_left  = rules_median_left,
  mean_cut     = rules_mean,
  display      = FALSE
)

str(shared, max.level = 1, list.len = 5)
#>  NULL

With display = TRUE (the default) the intersections are also printed to the console. To save them, pass a file name:

compare_rules(
  median_right = rules_median,
  median_left  = rules_median_left,
  mean_cut     = rules_mean,
  display      = FALSE,
  filename     = "intersections.csv"
)

Euler diagrams

An Euler diagram shows the same comparison visually: each circle is a rule set, and the numbers give how many rules fall in each region. rule_euler() accepts a list of two to four rules objects, and the list names become the set labels. Because the diagram is an area-proportional approximation, treat the shapes as a guide and rely on the printed counts for exact values.

rule_set_list <- list(
  "Median (right-closed)" = rules_median,
  "Median (left-closed)"  = rules_median_left,
  "Mean"                  = rules_mean
)

rule_euler(
  rule_set_list,
  title = "Mined rules for high eDNA concentration"
)

Colours, transparency, borders and text can all be customized. When set names are long, a legend can replace the labels on the diagram:

rule_euler(
  rule_set_list,
  title           = "Mined rules for high eDNA concentration",
  fill_color      = c("#7832ff", "#f5a623", "#2ca58d"),
  fill_alpha      = 0.4,
  stroke_color    = "grey30",
  stroke_size     = 1.2,
  text_size       = 12,
  show_legend     = TRUE,
  legend_position = "bottom"
)

Heatmaps

Heatmaps are better suited to inspecting individual rules. rule_heatmap() places antecedents on the x-axis and consequents on the y-axis, and shades each cell by "confidence" (the default), "support" or "lift".

A heatmap becomes unreadable with too many rules, so we first thin out the rule set. We mine rules for two consequents, remove redundant rules, sort, and keep the top 5:

rules_hm <- apriori(
  bt_median,
  parameter  = list(supp = 1/126, conf = 1/126, maxlen = 10, target = "rules"),
  appearance = list(
    rhs     = c("eDNAConc=high", "eFishCatch=high"),
    default = "lhs"
  ),
  control = list(verbose = FALSE)
)

rules_hm <- rules_hm[!is.redundant(rules_hm, measure = "confidence")]
rules_hm <- rules_hm[is.significant(rules_hm, alpha = 0.05)]
rules_hm <- sort(rules_hm, by = c("confidence", "lift", "support"))
rules_hm <- head(rules_hm, 5)

length(rules_hm)
#> [1] 5
rule_heatmap(
  rules_hm,
  metric      = "support",
  graph_title = "Support of top 5 mined rules"
)

Switch the metric and the colour gradient to look at confidence instead:

rule_heatmap(
  rules_hm,
  metric      = "confidence",
  graph_title = "Confidence of top 5 mined rules",
  low_color   = "#D4A221",
  high_color  = "darkgreen"
)

Antecedent and consequent combinations that do not appear in the rule set are left blank. Set include_zero = TRUE to fill them with zero, which makes the gaps explicit. Axis titles, text sizes, label angles and the legend are also adjustable:

rule_heatmap(
  rules_hm,
  metric            = "lift",
  graph_title       = "Lift of top 5 mined rules",
  x_axis_title      = "If (antecedent)",
  y_axis_title      = "Then (consequent)",
  x_axis_text_angle = 65,
  include_zero      = TRUE
)

rule_heatmap() returns a ggplot object, so you can add further ggplot2 layers or themes to the result.

Notes on interpretation

  • Discretization shapes the results. The comparison above shows that the same data can yield different rules depending on the cutoff and on whether boundaries are open or closed. It is worth checking that conclusions hold across reasonable choices.
  • Exploratory, not confirmatory. Association rules describe co-occurrence in the sample. They do not establish causes, and in a modest dataset like BrookTrout many rules may reflect chance.
  • Choose thresholds deliberately. Lower minimum support and minumum confidence thresholds produce many more rules. Use maxlen, appearance, and filtering to keep the output manageable.

References

Hahsler, M., Gruen, B., and Hornik, K. (2005). arules: A computational environment for mining association rules and frequent item sets. Journal of Statistical Software, 14(15), 1-25.

Nolan, K. P., Loeza-Quintana, T., Little, H. A., et al. (2023). Detection of brook trout in spatiotemporally separate locations using validated eDNA technology. Journal of Environmental Studies and Sciences, 13, 66-82. https://doi.org/10.1007/s13412-022-00800-x

Toth, N., Antonie, L., Hanner, R.H., Gillis, D.J., and Phillips, J.D. Mining association rules for targeted spatiotemporal aquatic environmental DNA (eDNA) sampling. bioRxiv. https://doi.org/10.64898/2026.09.16.752056.

Session information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] arules_1.7.15    Matrix_1.7-6     RulesTools_0.1.1 rmarkdown_2.32  
#> 
#> loaded via a namespace (and not attached):
#>  [1] eulerr_8.3.1       sass_0.4.10        generics_0.1.4     tidyr_1.3.2       
#>  [5] shape_1.4.6.1      lattice_0.23-1     lme4_2.0-6         mitml_0.4-5       
#>  [9] digest_0.6.39      magrittr_2.0.5     evaluate_1.0.5     grid_4.6.1        
#> [13] RColorBrewer_1.1-3 iterators_1.0.14   mice_3.19.0        fastmap_1.2.0     
#> [17] jomo_2.7-6         foreach_1.5.2      jsonlite_2.0.0     glmnet_5.1        
#> [21] nnet_7.3-21        backports_1.5.1    survival_3.8-12    purrr_1.2.2       
#> [25] scales_1.4.0       codetools_0.2-20   jquerylib_0.1.4    reformulas_0.4.4  
#> [29] Rdpack_2.6.6       cli_3.6.6          rlang_1.3.0        rbibutils_2.4.1   
#> [33] splines_4.6.1      withr_3.0.3        cachem_1.1.0       yaml_2.3.12       
#> [37] pan_2.0            otel_0.2.0         parallel_4.6.1     tools_4.6.1       
#> [41] nloptr_2.2.1       minqa_1.2.8        dplyr_1.2.1        ggplot2_4.0.3     
#> [45] boot_1.3-32        broom_1.0.13       rpart_4.1.27       buildtools_1.0.0  
#> [49] vctrs_0.7.3        R6_2.6.1           lifecycle_1.0.5    MASS_7.3-66       
#> [53] pkgconfig_2.0.3    pillar_1.11.1      bslib_0.12.0       gtable_0.3.6      
#> [57] glue_1.8.1         Rcpp_1.1.2         xfun_0.61          tibble_3.3.1      
#> [61] tidyselect_1.2.1   sys_3.4.3          knitr_1.52         farver_2.1.2      
#> [65] nlme_3.1-171       htmltools_0.5.9    labeling_0.4.3     maketools_1.3.2   
#> [69] compiler_4.6.1     S7_0.2.2