Ant Colony Optimization with antColony()

library(ShortForm)

How it works

The Ant Colony Optimization (ACO) algorithm (Dorigo & Stutzle, 2004; adapted for short-form construction by Leite, Huang, & Marcoulides, 2008) is based on the foraging behavior of ants: they search for food in a variety of directions, and the shortest paths accumulate the strongest pheromone trails, which then attract more ants.

Applied to short-form construction:

  1. A number of “ants” (candidate short forms) are sampled per iteration, with items selected with probability proportional to their current “pheromone” weight.
  2. Each candidate model is fit, and checked against a fit-based threshold test (fit.statistics.test).
  3. Candidates that pass the threshold have a “pheromone” strength computed from their fit (pheromone.calculation); the best candidate’s items have pheromone added to their weight, making them more likely to be sampled again.
  4. This repeats until the same solution is chosen for a set number of ants in a row (steps), or maxIterations ants have been tried in total.

A basic example

Every candidate item must already appear on its factor’s line in initialModelantColony() derives the factor names and each factor’s candidate item pool directly from that syntax, the same way simulatedAnnealing() and tabuSearch() do.

set.seed(58310)

result <- antColony(
  data = lavaan::HolzingerSwineford1939,
  ants = 2, evaporation = 0.7,
  initialModel = " visual  =~ x1 + x2 + x3
                   textual =~ x4 + x5 + x6
                   speed   =~ x7 + x8 + x9 ",
  itemsPerFactor = c(3, 3, 3),
  steps = 2,
  fit.indices = c("cfi"),
  fit.statistics.test = "(cfi > 0.6)",
  maxIterations = 2,
  parallel = FALSE,
  verbose = FALSE
)

result
#> Algorithm: Ant Colony Optimization
#> Total Run Time: 0.095 secs
#> 
#> Function call:
#> antColony(data = lavaan::HolzingerSwineford1939, ants = 2, evaporation = 0.7,
#>   initialModel = " visual =~ x1 + x2 + x3\n textual =~ x4 + x5 + x6\n speed
#>   =~ x7 + x8 + x9 ", itemsPerFactor = c(3, 3, 3), steps = 2, fit.indices =
#>   c("cfi"), fit.statistics.test = "(cfi > 0.6)", maxIterations = 2, parallel =
#>   FALSE, verbose = FALSE, sample.cov = NULL, sample.nobs = NULL, items = NULL,
#>   bifactor = NULL, lavaan.model.specs = list(model.type = "cfa", estimator
#>   = "default", ordered = NULL, int.ov.free = TRUE, int.lv.free = FALSE,
#>   auto.fix.first = TRUE, auto.fix.single = TRUE, auto.var = TRUE, auto.cov.lv.x
#>   = TRUE, auto.th = TRUE, auto.delta = TRUE, auto.cov.y = TRUE, std.lv = FALSE,
#>   group = NULL, group.label = NULL, group.equal = "loadings", group.partial =
#>   NULL, group.w.free = FALSE), pheromone.calculation = "gamma")
#> 
#> Final Model Syntax:
#> visual =~ x1 + x2 + x3
#> textual =~ x4 + x5 + x6
#> speed =~ x7 + x8 + x9
#> 
#> Fit Indices: cfi
#> Fit Test: (cfi > 0.6)
#> Final Model Values: cfi = 0.931

itemsPerFactor sets the target number of items to keep for each factor, in the order the factors appear in initialModel. Since this toy example already asks to keep all 3 items per factor, the “search” has only one candidate to find.

Inspecting the result

summary(result)
#> Algorithm: Ant Colony Optimization
#> Total Run Time: 0.095 secs
#> 
#> lavaan 0.7-2 ended normally after 35 iterations
#> 
#>   Estimator                                         ML
#>   Optimization method                           NLMINB
#>   Number of model parameters                        30
#> 
#>   Number of observations                           301
#> 
#> Model Test User Model:
#>                                                       
#>   Test statistic                                85.306
#>   Degrees of freedom                                24
#>   P-value (Chi-square)                           0.000
#> 
#> 
#> Final Model Syntax:
#> visual =~ x1 + x2 + x3
#> textual =~ x4 + x5 + x6
#> speed =~ x7 + x8 + x9
#> 
#> Fit Indices: cfi
#> Fit Test: (cfi > 0.6)
#> Final Model Values: cfi = 0.931

plot() shows several diagnostics: how the pheromone accumulates across runs, and how the mean standardized loadings/variance explained change.

plot(result)

You can also request a single panel:

plot(result, type = "pheromone")

A more realistic example

The toy example above doesn’t actually reduce the item bank. A more typical use case starts from a much larger item pool – here, the bundled exampleAntModel (56 items on a single Ability factor) and simulated_test_data:

data(exampleAntModel) # a character vector for a lavaan model
data(simulated_test_data)

abilityShortForm <- antColony(
  data = simulated_test_data,
  ants = 5, evaporation = 0.7,
  initialModel = exampleAntModel,
  itemsPerFactor = 20,
  steps = 3,
  fit.indices = c("cfi", "rmsea"),
  fit.statistics.test = "(cfi > 0.95)&(rmsea < 0.05)",
  maxIterations = 500
)

abilityShortForm

Fit indices, thresholds, and pheromone

Unlike simulatedAnnealing()/tabuSearch(), which optimize a single scalar criterion, antColony() uses two related but distinct arguments:

A candidate that fails fit.statistics.test contributes no pheromone at all, regardless of pheromone.calculation.

Ordered (categorical) data

antColony() works with categorical/ordered indicators by passing the appropriate lavaan.model.specs:

sim_model <- "
f1 =~ x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + x9 + x10
f2 =~ x11 + x12 + x13 + x14 + x15 + x16 + x17 + x18 + x19 + x20
f3 =~ x21 + x22 + x23 + x24 + x25 + x26 + x27 + x28 + x29 + x30"

sim_data <- cbind(
  psych::sim.rasch(nvar = 10)$items,
  psych::sim.rasch(nvar = 10)$items,
  psych::sim.rasch(nvar = 10)$items
)
colnames(sim_data) <- paste0("x", 1:30)

# only estimator and ordered are changed -- every other lavaan.model.specs
# element falls back to antColony()'s own default
example <- antColony(
  data = sim_data,
  ants = 5, evaporation = 0.7,
  initialModel = sim_model,
  lavaan.model.specs = list(estimator = "wlsmv", ordered = TRUE),
  itemsPerFactor = c(5, 5, 5),
  steps = 20,
  fit.indices = c("cfi.scaled"),
  fit.statistics.test = "(cfi.scaled > 0.90)",
  maxIterations = 500,
  parallel = TRUE
)

lavaan.model.specs accepts a partial list – any element you omit falls back to antColony()’s own default for that element, but every name you do supply must be a recognized lavaan() argument, or the call errors immediately (catching typos like estmator before a long run starts).

Bifactor models

Pass the name of the general factor as bifactor to have all of the retained items across the other factors also load on it:

bifactorModel <- "
visual  =~ x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + x9
textual =~ x4 + x5 + x6
speed   =~ x7 + x8 + x9"

antColony(
  data = lavaan::HolzingerSwineford1939,
  ants = 5, evaporation = 0.7,
  initialModel = bifactorModel,
  itemsPerFactor = c(6, 3, 3),
  bifactor = "visual",
  steps = 5, fit.indices = c("cfi"), fit.statistics.test = "(cfi > 0.9)",
  maxIterations = 100
)

Parallelization and progress output

antColony() evaluates the ants candidates within each iteration in parallel by default (parallel = TRUE); set parallel = FALSE for serial execution (as in the examples above, so output is deterministic). verbose controls whether per-ant progress is printed to the console – the full per-run history is always available afterward from the returned object’s summary and final_solution slots regardless of this setting.