---
title: "Comparing models: SSE, likelihood and cross-validation"
author: "George Kachergis"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette:
        fig_caption: yes
vignette: >
  %\VignetteIndexEntry{Comparing models: SSE, likelihood and cross-validation}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
library(XSLmodels)

knitr::opts_chunk$set(
  echo = TRUE,
  collapse = TRUE,
  comment = "#>",
  warning = FALSE
)
```

This vignette walks through comparing models on the package's data: fitting
each to a set of conditions, scoring the fits by SSE or by likelihood, and
checking them out of sample. To keep it quick it uses a few conditions and
small DEoptim searches. A real comparison would use all of `xsl_datasets`,
the default DEoptim settings and the registry's restarts (see `?xsl_fit`).

```{r data}
labels <- vapply(xsl_datasets, `[[`, "", "label")
conds <- xsl_datasets[labels %in% c("201", "212", "215", "220", "filt3E_6L", "freq369-3x3hiCD")]
# DEoptim advises a population of at least 10 per parameter; this one is
# smaller to keep the vignette fast, so its warnings about that are hidden
de <- DEoptim::DEoptim.control(NP = 15, itermax = 8, trace = FALSE)
models <- c("baseline", "decay", "uncfam", "rescorla_wagner")
```

# Fitting by SSE

By default a model is fit by minimizing the squared error between its
accuracy and people's on each test item, averaged over conditions weighted
by their number of participants. `get_group_model_fit()` looks up a model's
starting values, free parameters and bounds in the package's registry (see
`show_models()`):

```{r sse}
sse_fits <- lapply(models, \(m) get_group_model_fit(m, datasets = conds,
                                                    deoptim_control = de, seed = 1))
names(sse_fits) <- models
sapply(sse_fits, \(f) f$fit_result[[1]]$optim$bestval)
```

SSE says how close the predictions are, but it isn't a likelihood, so it
can't weigh a better fit against extra parameters.

# Fitting by likelihood

Every `xsl_datasets` condition has a `response_matrix`: how many participants
chose each object on each test item. With
`xslControl(objective = "binomial")` a model is instead fit by the
likelihood of how many people got each item right. With
`objective = "multinomial"`, it is fit by the likelihood of which object
each person chose.

Both likelihoods include a lapse rate: the probability that a response is a
uniform guess among the alternatives offered. This keeps one confident
wrong prediction from making the likelihood infinitely bad. It can be fixed
(`xslControl(lapse = )`, default .01) or fit along with the model's
parameters (`fit_lapse = TRUE`, or `"lapse"` in `xsl_fit()`'s `free`):

```{r binomial}
binom <- xslControl(objective = "binomial")
ll_fits <- lapply(models, \(m) get_group_model_fit(m, datasets = conds, control = binom,
                                                   deoptim_control = de, seed = 1,
                                                   fit_lapse = TRUE))
names(ll_fits) <- models
ic <- \(fits) do.call(rbind, lapply(fits, \(f) {
  r <- f$fit_result[[1]]
  data.frame(k = r$k, loglik = r$loglik, AIC = r$aic, BIC = r$bic,
             lapse = unname(r$optim$bestmem["lapse"]))
}))
ic(ll_fits)
```

`k` counts the fitted lapse too, and the BIC's sample size is the number of
human responses (`n_obs`).

The likelihoods treat each response as an independent draw from the model's
prediction. Real participants differ from one another, so the absolute size
of AIC or likelihood-ratio differences overstates the evidence; rankings are
more trustworthy than magnitudes.

# Which wrong answers?

Accuracy, and the binomial, only ask whether a choice was right. The
multinomial also scores *which* wrong object people chose, and errors are
often where models differ most. `choice_probabilities()` gives each test
item's alternatives, the model's probability of choosing each one, and how
many people did.

Here, for each fitted model, is how well its distribution over the wrong
answers matches people's, pooled over every item in these conditions (the
correlation between the two across foils):

```{r foils}
foil_cor <- function(mod, data) {
  pairs <- do.call(rbind, lapply(xsl_run(mod, data)$fits, \(f) {
    ch <- choice_probabilities(f$matrix, f$data)
    do.call(rbind, lapply(seq_len(nrow(ch)), \(i) {
      j <- match(ch$correct[i], ch$alternatives[[i]])
      q <- ch$p[[i]][-j]
      h <- ch$counts[[i]][-j]
      if (sum(h) == 0 || sum(q) == 0) return(NULL)
      data.frame(model = q / sum(q), people = h / sum(h))
    }))
  }))
  cor(pairs$model, pairs$people)
}
best <- \(m) ll_fits[[m]]$fit_result[[1]]$optim$bestmem
c(baseline = foil_cor(baseline(), conds),
  decay = foil_cor(decay(C = best("decay")[["C"]]), conds),
  uncfam = foil_cor(uncfam(X = best("uncfam")[["X"]], B = best("uncfam")[["B"]],
                           C = best("uncfam")[["C"]]), conds))
```

In these rough fits, the co-occurrence models' errors track people's better
than `uncfam()`'s do, even though `uncfam()` fits accuracy far better. A
model fit only to accuracy (or to the binomial) is never asked to get the
errors right, and what fits accuracy needn't fit the errors.

Fitting by the multinomial uses all of this information:

```{r multinomial}
multi <- xslControl(objective = "multinomial")
mn_fits <- lapply(c("decay", "uncfam"), \(m) get_group_model_fit(m, datasets = conds, control = multi,
                                                                  deoptim_control = de, seed = 1,
                                                                  fit_lapse = TRUE))
names(mn_fits) <- c("decay", "uncfam")
ic(mn_fits)
```

# Cross-validation

In-sample fit rewards flexibility. Cross-validation instead fits each model
to all but one fold of conditions and scores it on the held-out fold, so
every condition is scored at parameters fit without it. With a likelihood
objective, it reports the held-out negative log-likelihood (`test_nll`)
alongside the held-out SSE:

```{r cv}
cv <- lapply(c("decay", "uncfam"), \(m) get_crossvalidated_model_fit(
  m, n_folds = 3, datasets = conds, control = binom, deoptim_control = de,
  seed = 1, fit_lapse = TRUE)$cv_result)
names(cv) <- c("decay", "uncfam")
sapply(cv, \(r) c(held_out_nll = sum(r$test_nll), held_out_sse = mean(r$test_sse)))
```

Lower is better for both, though the two criteria needn't agree. Unlike AIC
and BIC, they need no assumption about how to count parameters. With these
small searches the fits are rough, so don't read much into the particular
numbers.

# Generalization

The strongest test is data from a different experiment altogether. The
datasets in `xsl_holdout_datasets` are never used by the group or
cross-validated fits. `vignette("XSLmodels")` (section 11) shows how to
score a fitted model on them.
