Common pitfalls / FAQ

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

If you hit something that isn’t here and feels surprising, please open an issue: most of the items below were added because real users tripped on them.

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.

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:

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 NAs, 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:

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 NAs. 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.

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 NAs, 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.

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:

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.

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. 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.

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 shows the setup, code, and how to compare the returned metrics.

See also. ?fit_pigauto (phylo_signal_gate, “Safety floor”) and the validation results, 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):

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:

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 for tested settings and limitations. The historical Pagel’s λ signal-sweep results 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.

# 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.

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.