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:
A and B, which is a measure of
joint probability;A that also contain B, which is a
measure of conditional probability;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.
BrookTrout datasetBrookTrout 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 0hist(
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?
apriori() works on categorical data, so the numeric
measurements must be discretized first.
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 48You 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 126Several 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 30dtize_col() can also fill in missing values before
discretizing, with na_fill = "mean" or
"median":
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.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.30bt_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 highCustom 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 halfLeft 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:
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 1424We 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 6Are 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)
#> NULLWith display = TRUE (the default) the intersections are
also printed to the console. To save them, pass a file name:
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 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] 5Switch 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.
BrookTrout many rules may reflect
chance.support and minumum confidence thresholds
produce many more rules. Use maxlen,
appearance, and filtering to keep the output
manageable.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.
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