---
title: "Common pitfalls / FAQ"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Common pitfalls / FAQ}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment  = "#>",
  fig.width  = 6,
  fig.height = 4
)
```

This vignette collects the questions early users most often hit when running
pigauto on their own data. Each section follows the same template:

- **Symptom**: the surprising output, in user voice.
- **Why this happens**: the mechanism in 2–3 sentences.
- **Diagnose**: 1–3 R commands that confirm the pattern.
- **Fix**: concrete code change with a short explanation.
- **See also**: links to the relevant help topic, article, or public
  validation results.

If you hit something that isn't here and feels surprising, please
[open an issue](https://github.com/itchyshin/pigauto/issues/new): most
of the items below were added because real users tripped on them.

<!-- slop-ok: Repeated Symptom/Why/Diagnose/Fix headings support scanning this FAQ. -->

## 1. "`impute()` stopped because my input is fully observed"

**Symptom.** You call `impute(df, tree)` on a fully-observed dataset and
it stops before training.

**Why this happens.** `impute()` fills `NA` cells in rows matched to the
tree. It stops before fitting only when the entire matched target table has
no missing cells; one complete column is not enough to trigger the stop if
another matched target cell is missing. For predictive assessment, hide
known values before fitting and compare predictions with the held-out truth,
or use `cross_validate()`.

**Diagnose.**

```{r section1-diag, eval = FALSE}
library(pigauto)
data(avonet300, tree300)
df <- avonet300
rownames(df) <- df$Species_Key
df$Species_Key <- NULL
sum(is.na(df))                  # if 0, there's nothing for impute() to do
```

**Fix.** Mask some cells before calling `impute()`, then evaluate
predictions only on the held-out cells:

```{r section1-fix, eval = FALSE}
library(pigauto)
data(avonet300, tree300)
df <- avonet300
rownames(df) <- df$Species_Key
df$Species_Key <- NULL

set.seed(1L)
n_hide <- min(30L, sum(!is.na(df$Mass)))
if (n_hide == 0L) stop("No observed Mass values are available to hide.")
hide   <- sample(which(!is.na(df$Mass)), n_hide)
df_obs <- df
df_obs$Mass[hide] <- NA          # hide up to 30 mass values

result <- impute(df_obs, tree300)

result$completed$Mass[hide]      # pigauto's imputations
df$Mass[hide]                    # held-out truth, for comparison
sum(result$imputed_mask[, "Mass"])  # number of values hidden above
```

For your own data with real `NA`s, the mask marks cells targeted for
imputation; a marked cell can still be `NA` if no prediction was available.
Extract by trait to preserve mixed column types:

```{r, eval=FALSE}
mask <- result$imputed_mask
imputed_values <- lapply(colnames(mask), function(nm) {
  result$completed[[nm]][mask[, nm]]
})
names(imputed_values) <- colnames(mask)
```

Check the selected values for remaining `NA`s. To keep only successful fills
for one trait, add `!is.na(result$completed[[nm]])` to that trait's mask.
The full-cell prediction in `result$prediction$imputed` is for diagnostics,
not a replacement for the completed data.

**See also.** `?impute` ("What gets imputed (read this first)"),
[issue #67](https://github.com/itchyshin/pigauto/issues/67).

## 2. "My ordinal trait predicted 100 % majority class"

**Symptom.** You impute an ordinal trait and the prediction is the
majority class for every species. For example, on `avonet300$Migration`
(K = 3 ordinal: Resident / Partial / Full), 300/0/0.

**Why this happens.** Two things compound:

1. If the whole matched target table has no `NA`s, `impute()` stops before
   fitting; use `cross_validate()` or mask known values for assessment (see
   Pitfall 1). A complete `Migration` column alone does not cause the stop.
2. At default settings (`n_imputations = 1L`, `pool_method = "median"`),
   a small ordinal trait whose marginal distribution is heavily skewed
   (AVONET `Migration` is ~78 % Resident / 14 % Partial / 8 % Full at
   n = 300) can have its calibrated gate snap to a corner that returns
   the majority class for every species.

**Diagnose.**

```{r section2-diag, eval = FALSE}
library(pigauto)
data(avonet300, tree300)
df <- avonet300; rownames(df) <- df$Species_Key; df$Species_Key <- NULL

table(df$Migration)              # check the marginal distribution
hide <- sample(which(!is.na(df$Migration)), 30L)
df$Migration[hide] <- NA
result <- impute(df, tree300, verbose = FALSE)
table(result$completed$Migration[hide])
```

**Fix.** Use `cross_validate()` to assess model-level fit settings on your own
preprocessed trait table. It does not compare the number of imputations or
pooling rule. To compare those `impute()` choices, score each result against
the same prespecified, hidden measurements, as in the example below. First
prepare the input for the model-level cross-validation check:

```r
pd <- preprocess_traits(df, tree300)
cv <- cross_validate(pd, tree300)
```

A closed gate is a calibration
outcome on the validation data; it does not by itself establish high
phylogenetic signal or explain why the GNN failed to help. Check validation
size and compare candidate predictions under the same held-out design. For
an imbalanced ordinal trait, more draws with `pool_method = "mode"` is one
candidate to evaluate, not a resolution or package-wide recommendation.

```{r section2-fix, eval = FALSE}
library(pigauto)
data(avonet300, tree300)
df <- avonet300; rownames(df) <- df$Species_Key; df$Species_Key <- NULL

set.seed(1L)
n_hide <- min(30L, sum(!is.na(df$Migration)))
if (n_hide == 0L) stop("No observed Migration values are available to hide.")
hide   <- sample(which(!is.na(df$Migration)), n_hide)
df_obs <- df
df_obs$Migration[hide] <- NA

# Default settings: prone to majority-class collapse on imbalanced K = 3
result <- impute(df_obs, tree300, verbose = FALSE)
table(result$completed$Migration[hide], df$Migration[hide])

# Candidate for K = 3 ordinal: more draws + mode pooling
result_mode <- impute(df_obs, tree300, n_imputations = 20L,
                      pool_method = "mode", verbose = FALSE)
table(result_mode$completed$Migration[hide], df$Migration[hide])
```

**See also.** `?impute` ("Imbalanced K-class traits") and
[issue #68](https://github.com/itchyshin/pigauto/issues/68). The mode-pooling
comparison was a three-seed study on an AVONET subset; its result is specific
to that design.

## 3. "The gate stays closed and the GNN seems to do nothing"

This section applies when you fit with `gnn = TRUE`. With the default
`gnn = FALSE` no GNN is trained, so `r_cal_gnn` is 0 by construction and
predictions come from the calibrated baseline.

**Symptom.** You expected the GNN to improve predictions, but inspecting the
fitted model shows the calibrated gate is fully or near-fully closed
(`r_cal_gnn` ≈ 0): the GNN contributes little or nothing to the calibrated
prediction.

**Why this happens.** This is a possible calibration outcome, not by itself
a sign of high phylogenetic signal. With `safety_floor = FALSE`, the mean
candidate is absent; with `safety_floor = TRUE`, calibration can also choose
the grand mean. After training, pigauto compares the baseline and GNN
predictions on held-out validation cells. When `safety_floor = TRUE`, it also
compares a grand-mean prediction. These candidates receive nonnegative weights
that add to one. If adding the GNN does not improve the best available
combination of the baseline and, when enabled, the grand mean, its weight can
be `r_cal_gnn = 0`. The grand mean is not a candidate when
`safety_floor = FALSE`.
The result depends on the validation data and metric; inspect validation size
and compare the baseline, GNN, and any mean candidate before interpreting it.

**Diagnose.**

This diagnostic fits a GNN, so the optional torch runtime must be installed
and available. The explicit `gnn = TRUE` argument matters: without it,
the default fit trains no network and its GNN gate is zero by construction.

```{r section3-diag, eval = FALSE}
library(pigauto)
data(avonet300, tree300)
df <- avonet300; rownames(df) <- df$Species_Key; df$Species_Key <- NULL
set.seed(1L)
hide <- sample(which(!is.na(df$Migration)), 30L)
df_obs <- df
df_obs$Migration[hide] <- NA
fit <- impute(df_obs, tree300, gnn = TRUE, verbose = FALSE)$fit

# Per-latent-column calibrated gates:
fit$r_cal_bm        # r assigned to the BM baseline
fit$r_cal_gnn       # r assigned to the GNN delta
fit$r_cal_mean      # r assigned to the grand mean
```

A row where `r_cal_gnn` is small (< 0.1) means the gate has effectively
closed for that latent column.

**Fix.** First treat the closed gate as the calibration result for this
validation split, not as evidence about signal strength. Compare held-out
predictions from the baseline and GNN under the same data split; check that
the validation set is large enough for the comparison. If you suspect the GNN
should help (for example, you added covariates or expect cross-trait
structure), also check that those inputs vary and are available:

For the paired comparison, run `cross_validate()` twice on the same prepared
data, with the same `seeds` value and `gnn = FALSE` and `gnn = TRUE`. This
reuses the same held-out folds for both fits. The [GNN guide's comparison
example](gnn-architecture.html#run-and-check-your-own-data) shows the setup,
code, and how to compare the returned metrics.

- Check the validation set is not pathologically small (the
  "small validation set" warning during fitting is a red flag: see
  `?fit_pigauto` "Calibration at small n").
- Verify covariates are not all-NA or constant after preprocessing.
- For ordinal / categorical traits, see Pitfall 2. Mode pooling is a
  candidate to compare on held-out data, not a guaranteed remedy.

**See also.** `?fit_pigauto` (`phylo_signal_gate`, "Safety floor") and
[the validation results](validation-results.html), which report tested
settings and their limits.

### Repeated observations, seeds and interval widths

With multiple rows per species, the baseline aggregates observations and
covariates to species level. With `gnn = FALSE`, it cannot use row-specific
covariate differences to refine predictions within a species. Joint baseline
routes can omit covariates altogether; check the selected route and warnings.
The GNN can use row-level covariates, but fitted predictions can vary with the
seed. Compare several seeds on the same held-out cells to assess stability;
a fixed seed makes a run repeatable, not necessarily representative.

For an untransformed continuous trait, a global per-trait conformal score gives
the same interval width for all rows in a fit. It does not adapt the width to
how many observations a species has. Transformations and other scoring methods
can produce a different pattern. Held-out coverage is useful to report, but
coverage in one dataset does not establish conditional coverage for each species.

## 4. "How do I know if my dataset has enough phylogenetic signal?"

**Symptom.** You aren't sure whether phylogenetic information is useful for
your dataset.

**Why this matters.** A lambda estimate describes fitted phylogenetic
structure; it does not determine predictive performance on a new dataset.
Use held-out evaluation rather than a generic threshold to compare a
phylogenetic workflow with an appropriate alternative.

**Diagnose.** The fitted object stores the per-trait λ values used by
`phylo_signal_gate` (they are computed only when the gate is switched on,
which is no longer the default):

```{r section4-diag, eval = FALSE}
library(pigauto)
data(avonet300, tree300)
df <- avonet300; rownames(df) <- df$Species_Key; df$Species_Key <- NULL

set.seed(1L)
hide <- sample(which(!is.na(df$Mass)), 30L)
df_obs <- df
df_obs$Mass[hide] <- NA
fit <- impute(df_obs, tree300, phylo_signal_gate = TRUE)$fit
fit$phylo_signal_per_trait
```

The output reports λ on the observed cells where it can be estimated. Discrete
traits use the package's internal continuous proxy on the latent/liability
scale.

**Fix.** Use the lambda estimate as a diagnostic, then run
`cross_validate()` on a preprocessed input and compare candidate workflows
under the same held-out design. If phylogenetic structure is weak, consider
whether a non-phylogenetic analysis is more appropriate for your scientific
question:

```r
library(pigauto)
data(avonet300, tree300)
df <- avonet300; rownames(df) <- df$Species_Key; df$Species_Key <- NULL

pd <- preprocess_traits(df, tree300)
cv <- cross_validate(pd, tree300)
```

**See also.** `?fit_pigauto` (`phylo_signal_gate`) and the
[validation results](validation-results.html) for tested settings and
limitations. The [historical Pagel’s λ signal-sweep results](https://github.com/itchyshin/pigauto/blob/main/script/bench_signal_sweep.md)
report one design with 300 species, 30% missingness, and three replicates;
they do not establish general expected performance.

## 5. "Predictions are way bigger than anything I observed"

**Symptom.** A masked log-transformed continuous trait (body mass,
seed mass, fish weight) predicts a value 50–100× larger than anything
observed. On AVONET, the canonical case is the cassowary: truth ≈ 35 kg,
predicted up to ~540 kg.

**Why this happens.** A single prediction can be far beyond the observed
range because a modest extrapolation on the transformed latent scale becomes
large after inverse transformation. Log-transformed continuous traits use
`exp()`; log1p-transformed count and positive `zi_count` magnitudes use
`expm1()`. A single `impute()` prediction is not an MC-dropout draw.
Stochastic tail variation can arise when you request multiple imputations
with `draws_method = "mc_dropout"`; a small number of draws can then leave
the pooled value sensitive to a rare tail draw.

**Diagnose.**

This diagnostic assumes you have already created `df` and `result` by
running `impute()`; here, `df` is the original trait table and `result` is
the returned `pigauto_result`.

```{r section5-diag, eval = FALSE}
# After running impute(), check whether any imputation exceeds the
# observed maximum by an unrealistic factor:
predicted_mass <- result$completed$Mass[result$imputed_mask[, "Mass"]]
obs_max <- max(df$Mass, na.rm = TRUE)
sum(predicted_mass > 5 * obs_max)
```

A non-zero count is a signal of tail extrapolation.

**Fix.** `clamp_outliers = TRUE` caps post-back-transform
predictions for log-transformed continuous, count, and `zi_count`
magnitude traits at `obs_max * clamp_factor` (default 5). This is
opt-in because a cap changes imputed values and can hide genuinely extreme
species. Check units and species-name matching first. Assess the prediction
rule by hiding known values and comparing predictions with their truth. Use a
cap only when the chosen limit is defensible for the trait and study system,
and report the selected multiplier.

```{r section5-fix, eval = FALSE}
library(pigauto)
data(avonet300, tree300)
df <- avonet300; rownames(df) <- df$Species_Key; df$Species_Key <- NULL
set.seed(1L)
n_hide <- min(30L, sum(!is.na(df$Mass)))
if (n_hide == 0L) stop("No observed Mass values are available to hide.")
hide <- sample(which(!is.na(df$Mass)), n_hide)
df_obs <- df
df_obs$Mass[hide] <- NA

result <- impute(df_obs, tree300,
                 clamp_outliers = TRUE,
                 clamp_factor   = 5,
                 verbose = FALSE)
```

**See also.** `?impute` (`clamp_outliers`, `clamp_factor` arguments). The
Mass diagnosis and clamp comparison were historical analyses on specific
data and settings; they do not establish a general prediction rule.
