R/nmfae.R

Defines functions nmf.rrr.DOT plot.nmfae.kernel.beta.cv nmf.rrr.kernel.beta.cv plot.nmfae.cv nmf.rrr.cv plot.nmfae.ecv nmf.rrr.rank nmf.rrr.ecv plot.predict.nmfae predict.nmfae nmf.rrr.heatmap print.summary.nmfae.inference summary.nmfae.inference print.summary.nmfae summary.nmfae plot.nmfae nmf.rrr.rename nmf.rrr.inference nmf.rrr

Documented in nmf.rrr nmf.rrr.cv nmf.rrr.DOT nmf.rrr.ecv nmf.rrr.heatmap nmf.rrr.inference nmf.rrr.kernel.beta.cv nmf.rrr.rank nmf.rrr.rename plot.nmfae plot.nmfae.cv plot.nmfae.ecv plot.nmfae.kernel.beta.cv plot.predict.nmfae predict.nmfae print.summary.nmfae print.summary.nmfae.inference summary.nmfae summary.nmfae.inference

# nmfae.R
# 3-layer NMF (NMF-AE): Y1 ≈ X1 C X2 Y2
# Author: Kenichi Satoh
# Date: 2026-03-07
#
# Depends: nmfkc
# Suggests: DiagrammeR, DiagrammeRsvg, rsvg (for DOT graph rendering)

#' @title Three-Layer Non-negative Matrix Factorization (NMF-AE)
#' @description
#' \code{nmfae} fits a three-layer nonnegative matrix factorization model
#' \eqn{Y_1 \approx X_1 \Theta X_2 Y_2}, where \eqn{X_1} is a decoder basis
#' (column sum 1), \eqn{\Theta} is a bottleneck parameter matrix,
#' \eqn{X_2} is an encoder basis (row sum 1), and \eqn{Y_2} is the input matrix.
#'
#' When \code{Y2 = Y1}, the model acts as a non-negative autoencoder.
#' When \code{Y1 != Y2}, it acts as a heteroencoder.
#'
#' Initialization uses a three-step NMF procedure via \code{\link{nmfkc}}:
#' (1) \code{nmfkc(Y1, rank=Q)} to obtain \eqn{X_1},
#' (2) \code{nmfkc(Y1, A=Y2, rank=Q)} with fixed \eqn{X_1} to obtain \eqn{C = \Theta X_2},
#' (3) \code{nmfkc(Y2, rank=R)} to factor \eqn{C} into \eqn{\Theta} and \eqn{X_2}.
#'
#' @param Y1 Output matrix \eqn{Y_1} (P1 x N). Non-negative. May contain \code{NA}s
#'   (handled via \code{Y1.weights}).
#' @param Y2 Input matrix \eqn{Y_2} (P2 x N). Non-negative. Default is \code{Y1} (autoencoder).
#' @param rank1 Integer. Rank of the response basis \eqn{X_1} (P1 x Q). Default is 2.
#' @param rank2 Integer. Rank of the covariate basis \eqn{X_2} (R x P2).
#'   Default (\code{NULL}) sets \code{rank2 = rank1}.
#' @param rank,rank.encoder Deprecated aliases of \code{rank1} / \code{rank2},
#'   kept for backward compatibility (\code{Q} / \code{R} are also accepted via
#'   \code{...}).
#' @param epsilon Positive convergence tolerance. Default is \code{1e-4}.
#' @param maxit Maximum number of multiplicative update iterations. Default is 5000.
#' @param verbose Logical. If \code{TRUE}, prints progress messages during fitting. Default is \code{FALSE}.
#' @param ... Additional arguments:
#'   \describe{
#'     \item{\code{method}}{Objective function: Euclidean distance
#'       \code{"EU"} (default) or Kullback-Leibler divergence \code{"KL"}.
#'       Both use Lee-Seung multiplicative updates for the three factors
#'       \eqn{X_1, \Theta, X_2} of \eqn{Y_1 \approx X_1 \Theta X_2 Y_2}; for
#'       \code{"KL"} the residual SE \code{sigma} is \code{NA} (not on the
#'       data scale).}
#'     \item{\code{Y1.weights}}{Optional non-negative weight matrix
#'       (P1 x N) or vector for \eqn{Y_1}, analogous to the
#'       \code{weights} argument of \code{\link[stats]{lm}}.  Loss becomes
#'       \eqn{\sum W_{ij} \, (Y_{1,ij} - \hat Y_{1,ij})^2}
#'       (\code{lm()}-style, \strong{linear} in \eqn{W}).  Logical
#'       matrices (\code{TRUE} / \code{FALSE}) are also accepted.
#'       Typical ECV / CV usage passes a binary mask
#'       \eqn{W \in \{0,1\}} for held-out elements; real-valued weights
#'       for importance weighting are also supported.  Default: if
#'       \code{Y1} has \code{NA}, a binary mask is auto-generated
#'       (0 for \code{NA}, 1 elsewhere).}
#'     \item{\code{C.L1}}{L1 regularization parameter for \eqn{C}. Default is 0.}
#'     \item{\code{X1.L2.ortho}}{L2 orthogonality regularization for \eqn{X_1} columns. Default is 0.}
#'     \item{\code{X2.L2.ortho}}{L2 orthogonality regularization for \eqn{X_2} rows. Default is 0.}
#'     \item{\code{seed}}{Integer seed for reproducibility. Default is 123.}
#'     \item{\code{nstart}}{Number of random restarts for the \code{nmfkc()}
#'       initialisation steps (passed to the k-means initialiser of the
#'       \eqn{X_1} and \eqn{X_2} factorisations). Default \code{1}
#'       (single start; the historical behaviour). A larger value
#'       (e.g.\ 10-20) gives a more stable initialisation and is recommended
#'       before inference.}
#'     \item{\code{print.trace}}{Logical. If \code{TRUE}, prints progress. Default is \code{FALSE}.}
#'   }
#'
#' @return An object of class \code{"nmfae"}, a list with components:
#' \item{X1}{Decoder basis matrix (P1 x Q), column sum 1.}
#' \item{C}{Parameter matrix (Q x R).}
#' \item{X2}{Encoder basis matrix (R x P2), row sum 1.}
#' \item{Y1hat}{Fitted values \eqn{X_1 \Theta X_2 Y_2} (P1 x N).}
#' \item{rank}{Named integer vector \code{c(Q, R)}.}
#' \item{method}{Objective used (\code{"EU"} or \code{"KL"}).}
#' \item{objfunc}{Final objective value.}
#' \item{objfunc.iter}{Objective values by iteration.}
#' \item{r.squared}{\eqn{\mathrm{cor}(Y, \widehat Y)^2} (Pearson; in \eqn{[0,1]}).}
#' \item{r.squared.uncentered}{Uncentered \eqn{R^2 = 1 - \|Y - \widehat Y\|_F^2 / \|Y\|_F^2} (baseline = zero matrix).}
#' \item{r.squared.centered}{Row-mean centered \eqn{1 - \|Y - \widehat Y\|_F^2 / \|Y - \bar Y_{p\cdot}\|_F^2}.}
#' \item{niter}{Number of iterations performed.}
#' \item{runtime}{Elapsed time as a \code{difftime} object.}
#' \item{n.missing}{Number of missing (or zero-weighted) elements in \eqn{Y_1}.}
#' \item{n.total}{Total number of elements in \eqn{Y_1} (P1 x N).}
#'
#' @section Lifecycle:
#' This function is \strong{experimental}. The interface may change in future versions.
#'
#' @seealso \code{\link{nmfae.inference}}, \code{\link{predict.nmfae}}, \code{\link{nmfae.ecv}}, \code{\link{nmfae.DOT}}, \code{\link{nmfkc}}
#' @export
#' @source Satoh, K. (2025). Applying Non-negative Matrix Factorization with Covariates
#'   to Multivariate Time Series. \emph{Japanese Journal of Statistics and Data Science}.
#' @references
#' Lee, D. D. and Seung, H. S. (2001). Algorithms for Non-negative Matrix Factorization.
#'   \emph{Advances in Neural Information Processing Systems}, 13.
#'
#' Saha, S. et al. (2022). Hierarchical Deep Learning Neural Network (HiDeNN):
#'   An Artificial Intelligence (AI) Framework for Computational Science and Engineering.
#'   \emph{Computer Methods in Applied Mechanics and Engineering}, 399.
#' @examples
#' # Autoencoder example
#' Y <- matrix(c(1,0,1,0, 0,1,0,1, 1,1,0,0), nrow=3, byrow=TRUE)
#' res <- nmf.rrr(Y, rank1=2, rank2=2)
#' res$r.squared
#'
#' # Heteroencoder example
#' Y1 <- matrix(c(1,0,0,1), nrow=2)
#' Y2 <- matrix(runif(8), nrow=4)
#' res2 <- nmf.rrr(Y1, Y2, rank1=2, rank2=2)
#'
nmf.rrr <- function(Y1, Y2 = Y1, rank1 = 2, rank2 = NULL,
                  epsilon = 1e-4, maxit = 5000, verbose = FALSE, ...,
                  rank = NULL, rank.encoder = NULL) {

  cl <- match.call()

  extra_args <- list(...)
  # rank1 = response basis X1, rank2 = covariate basis X2 (default rank1).
  # Legacy rank / rank.encoder (formals, exact-matched) and Q / R (via ...).
  if (is.null(rank))          rank <- rank1
  if (!is.null(extra_args$Q)) rank <- extra_args$Q
  if (is.null(rank.encoder))  rank.encoder <- rank2
  if (!is.null(extra_args$R)) rank.encoder <- extra_args$R
  if (is.null(rank.encoder))  rank.encoder <- rank
  Q <- rank
  R <- rank.encoder
  Y1.weights  <- if (!is.null(extra_args$Y1.weights))  extra_args$Y1.weights  else NULL
  C.L1        <- if (!is.null(extra_args$C.L1))        extra_args$C.L1        else 0
  X1.L2.ortho <- if (!is.null(extra_args$X1.L2.ortho)) extra_args$X1.L2.ortho else 0
  X2.L2.ortho <- if (!is.null(extra_args$X2.L2.ortho)) extra_args$X2.L2.ortho else 0
  seed        <- if (!is.null(extra_args$seed))        extra_args$seed        else 123
  ## Multi-start for the nmfkc() initialisation steps (k-means nstart).
  ## Default 1 keeps the historical single-start behaviour.
  nstart      <- if (!is.null(extra_args$nstart))      extra_args$nstart      else 1
  ## Basis-init method forwarded to the two internal nmfkc() init steps
  ## (X1 from Y1, X2 from Y2). Default "kmeans"; "kmeans++" etc. are accepted.
  ## Only string methods are forwarded (a user matrix is not meaningful for the
  ## paired bases); matrices fall back to the default.
  X.init      <- if (!is.null(extra_args$X.init))      extra_args$X.init      else "kmeans"
  X.init.method <- if (is.character(X.init)) X.init else "kmeans"
  ## Objective: Euclidean distance "EU" (default) or KL divergence "KL".
  method      <- if (!is.null(extra_args$method))
                   match.arg(extra_args$method, c("EU", "KL")) else "EU"
  print.trace <- verbose
  if (!is.null(extra_args$print.trace)) print.trace <- extra_args$print.trace  # backward compat

  # --- Input validation ---
  Y1 <- as.matrix(Y1); storage.mode(Y1) <- "double"
  Y2 <- as.matrix(Y2); storage.mode(Y2) <- "double"
  if (min(Y2, na.rm = TRUE) < 0) stop("Y2 must be non-negative.")
  if (ncol(Y1) != ncol(Y2)) stop("Y1 and Y2 must have the same number of columns (N).")

  P1 <- nrow(Y1); P2 <- nrow(Y2); N <- ncol(Y1)
  eps <- 1e-10
  start.time <- Sys.time()

  # --- Y1.weights handling (cf. nmfkc Y.weights) ---
  # Vector -> matrix expansion
  if (!is.null(Y1.weights) && is.vector(Y1.weights)) {
    if (length(Y1.weights) == N) {
      Y1.weights <- matrix(Y1.weights, nrow = P1, ncol = N, byrow = TRUE)
    } else if (length(Y1.weights) == 1) {
      Y1.weights <- matrix(Y1.weights, nrow = P1, ncol = N)
    } else {
      stop("Length of Y1.weights vector must match ncol(Y1) or be 1.")
    }
  }
  # NULL: auto-detect NAs
  if (is.null(Y1.weights)) {
    if (any(is.na(Y1))) {
      Y1.weights <- matrix(1, nrow = P1, ncol = N)
      Y1.weights[is.na(Y1)] <- 0
      Y1[is.na(Y1)] <- 0
      if (print.trace) message("  Notice: NAs in Y1 detected, Y1.weights set automatically.")
    }
  } else {
    if (!is.matrix(Y1.weights)) Y1.weights <- as.matrix(Y1.weights)
    if (!all(dim(Y1.weights) == dim(Y1))) stop("Dimension mismatch between Y1 and Y1.weights.")
    Y1.weights[is.na(Y1.weights)] <- 0
    Y1[is.na(Y1)] <- 0
    Y1[Y1.weights == 0] <- 0
  }
  # Determine code path
  has.weights <- !is.null(Y1.weights) && any(Y1.weights != 1)
  if (!has.weights) Y1.weights <- NULL

  if (min(Y1, na.rm = TRUE) < 0) stop("Y1 must be non-negative.")

  if (print.trace) {
    message(sprintf("Y1(%d,%d) ~ X1(%d,%d) C(%d,%d) X2(%d,%d) Y2(%d,%d)",
                    P1, N, P1, Q, Q, R, R, P2, P2, N))
  }

  # === Initialization using nmfkc ===
  # Step 1: X1 from nmfkc(Y1, rank=Q)
  if (print.trace) message("  Init step 1: nmfkc(Y1, rank=Q)...")
  res1 <- nmfkc(Y1, rank = Q, seed = seed, nstart = nstart, print.dims = FALSE,
                X.init = X.init.method, Y.weights = Y1.weights)
  X1 <- res1$X  # P1 x Q, column sum 1

  # Step 2: CX2 = C X2 with X1 fixed
  if (print.trace) message("  Init step 2: nmfkc(Y1, A=Y2, X.restriction='fixed')...")
  res2 <- nmfkc(Y1, A = Y2, rank = Q,
                 X.init = X1, X.restriction = "fixed",
                 seed = seed, print.dims = FALSE,
                 Y.weights = Y1.weights)
  CX2_init <- res2$C  # Q x P2

  # Step 3: X2 from nmfkc(Y2, rank=R), then C from CX2 ≈ C X2
  if (print.trace) message("  Init step 3: nmfkc(Y2, rank=R)...")
  res3 <- nmfkc(Y2, rank = R, seed = seed, nstart = nstart, print.dims = FALSE,
                X.init = X.init.method)
  X2 <- t(res3$X)             # R x P2, row sum 1
  C <- CX2_init %*% t(X2)     # Q x R, non-negative

  # Precompute (only needed for unweighted path)
  if (!has.weights) Y2Y2t <- tcrossprod(Y2)  # P2 x P2

  # === Multiplicative updates ===
  objfunc.iter <- numeric(maxit)

  W <- Y1.weights  # alias for readability (NULL when unweighted)

  for (iter in 1:maxit) {
    # 1. Update X1: Y1 ≈ X1 F, F = C X2 Y2
    F_mat <- C %*% X2 %*% Y2                           # Q x N
    if (method == "EU") {
      if (has.weights) {
        num_X1 <- (W * Y1) %*% t(F_mat)
        den_X1 <- (W * (X1 %*% F_mat)) %*% t(F_mat) + eps
      } else {
        num_X1 <- tcrossprod(Y1, F_mat)
        den_X1 <- X1 %*% tcrossprod(F_mat) + eps
      }
    } else {                                           # KL: ratio Y1/Y1hat
      ratio <- Y1 / (X1 %*% F_mat + eps)
      if (has.weights) ratio <- W * ratio
      num_X1 <- ratio %*% t(F_mat)
      den_X1 <- (if (has.weights) W %*% t(F_mat)
                 else matrix(rowSums(F_mat), P1, Q, byrow = TRUE)) + eps
    }
    if (X1.L2.ortho > 0) {
      X1tX1 <- crossprod(X1); diag(X1tX1) <- 0
      den_X1 <- den_X1 + X1.L2.ortho * (X1 %*% X1tX1)
    }
    X1 <- X1 * (num_X1 / den_X1)

    # 2. Normalize X1 columns -> absorb scale into C (left)
    cs <- colSums(X1)
    X1 <- sweep(X1, 2, cs, "/")
    C <- sweep(C, 1, cs, "*")

    # 3. Update C: Y1 ≈ X1 C G, G = X2 Y2
    G <- X2 %*% Y2                                     # R x N
    if (method == "EU") {
      if (has.weights) {
        num_C <- crossprod(X1, W * Y1) %*% t(G)
        den_C <- crossprod(X1, W * (X1 %*% C %*% G)) %*% t(G) + eps
      } else {
        num_C <- crossprod(X1, Y1) %*% t(G)
        den_C <- crossprod(X1) %*% C %*% tcrossprod(G) + eps
      }
      if (C.L1 > 0) den_C <- den_C + (C.L1 / 2)
    } else {                                           # KL
      ratio <- Y1 / (X1 %*% C %*% G + eps)
      if (has.weights) ratio <- W * ratio
      num_C <- crossprod(X1, ratio) %*% t(G)
      den_C <- (if (has.weights) crossprod(X1, W) %*% t(G)
                else outer(colSums(X1), rowSums(G))) + eps
      if (C.L1 > 0) den_C <- den_C + C.L1
    }
    C <- C * (num_C / den_C)

    # 4. Update X2: Y1 ≈ H X2 Y2, H = X1 C
    H <- X1 %*% C                                      # P1 x R
    if (method == "EU") {
      if (has.weights) {
        num_X2 <- crossprod(H, W * Y1) %*% t(Y2)
        den_X2 <- crossprod(H, W * (H %*% X2 %*% Y2)) %*% t(Y2) + eps
      } else {
        num_X2 <- crossprod(H, Y1) %*% t(Y2)
        den_X2 <- crossprod(H) %*% X2 %*% Y2Y2t + eps
      }
    } else {                                           # KL
      ratio <- Y1 / (H %*% X2 %*% Y2 + eps)
      if (has.weights) ratio <- W * ratio
      num_X2 <- crossprod(H, ratio) %*% t(Y2)
      den_X2 <- (if (has.weights) crossprod(H, W) %*% t(Y2)
                 else outer(colSums(H), rowSums(Y2))) + eps
    }
    if (X2.L2.ortho > 0) {
      X2X2t <- tcrossprod(X2); diag(X2X2t) <- 0
      den_X2 <- den_X2 + X2.L2.ortho * (X2X2t %*% X2)
    }
    X2 <- X2 * (num_X2 / den_X2)

    # 5. Normalize X2 rows -> absorb scale into C (right)
    rs <- rowSums(X2)
    X2 <- sweep(X2, 1, rs, "/")
    C <- sweep(C, 2, rs, "*")

    # Objective function (with regularization penalties)
    # lm()-style weighted least squares: L = sum(W * (Y1 - Y1hat)^2).
    # The MU (num_X1, num_C, etc.) carries W linearly, so reporting the
    # linear-W objective here keeps MU target and reported loss consistent.
    # For binary W in {0,1} (standard ECV / NA-mask case) this is identical
    # to sum((W*(Y1-Y1hat))^2) since W == W^2.
    Y1hat <- X1 %*% C %*% X2 %*% Y2
    if (method == "EU") {
      if (has.weights) {
        obj <- sum(W * (Y1 - Y1hat)^2)
      } else {
        obj <- sum((Y1 - Y1hat)^2)
      }
    } else {                                           # KL: sum(-Y1 log Y1hat + Y1hat)
      Wm <- if (has.weights) W else 1
      obj <- sum(-(Wm * Y1) * log(Y1hat + eps) + Wm * Y1hat)
    }
    if (C.L1 > 0) obj <- obj + C.L1 * sum(C)
    if (X1.L2.ortho > 0) {
      X1tX1 <- crossprod(X1); diag(X1tX1) <- 0
      obj <- obj + (X1.L2.ortho / 2) * sum(X1tX1^2)
    }
    if (X2.L2.ortho > 0) {
      X2X2t <- tcrossprod(X2); diag(X2X2t) <- 0
      obj <- obj + (X2.L2.ortho / 2) * sum(X2X2t^2)
    }
    objfunc.iter[iter] <- obj

    if (print.trace && (iter %% 100 == 0 || iter == 1)) {
      message(sprintf("  iter %5d: objfunc = %.6f", iter, objfunc.iter[iter]))
    }

    # Convergence check
    if (iter > 1) {
      rel_change <- abs(objfunc.iter[iter] - objfunc.iter[iter-1]) /
                       (objfunc.iter[iter-1] + eps)
      if (rel_change < epsilon) {
        if (print.trace) message(sprintf("  Converged at iter %d", iter))
        break
      }
    }
  }
  ## Warn when the MU loop exhausts maxit without meeting the
  ## relative-tolerance criterion (matches nmfkc() / nmf.sem() convention).
  if (iter == maxit && exists("rel_change") && rel_change >= epsilon)
    warning(paste0("maximum iterations (", maxit, ") reached..."))

  niter <- iter
  objfunc.iter <- objfunc.iter[1:niter]
  diff.time <- as.numeric(difftime(Sys.time(), start.time, units = "secs"))

  # --- Reorder bases by centroid position (cf. nmfkc) ---
  # X1 (P1 x Q): sort columns by weighted centroid of row indices
  if (Q > 1) {
    idx1 <- order(matrix(seq_len(P1) / P1, nrow = 1) %*% X1)
    X1 <- X1[, idx1, drop = FALSE]
    C <- C[idx1, , drop = FALSE]
  }
  # X2 (R x P2): sort rows by weighted centroid of column indices
  if (R > 1) {
    idx2 <- order(X2 %*% matrix(seq_len(P2) / P2, ncol = 1))
    X2 <- X2[idx2, , drop = FALSE]
    C <- C[, idx2, drop = FALSE]
  }

  # --- Assign Resp/Cov names (response basis X1, covariate basis X2) ---
  colnames(X1) <- paste0("Resp", 1:Q)
  rownames(C)  <- paste0("Resp", 1:Q)
  colnames(C)  <- paste0("Cov", 1:R)
  rownames(X2) <- paste0("Cov", 1:R)

  Y1hat <- X1 %*% C %*% X2 %*% Y2
  objfunc <- utils::tail(objfunc.iter, 1)

  # R-squared, sigma, mae, and missing count
  if (has.weights) {
    valid <- (W > 0)
    n.missing <- sum(!valid)
    n.valid <- sum(valid)
    r2_all <- .r.squared.all(Y1, Y1hat, Y.weights = W)
    sigma <- if (method == "EU") sqrt(objfunc / n.valid) else NA_real_
    mae <- mean(abs(Y1[valid] - Y1hat[valid]))
  } else {
    n.missing <- 0L
    n.valid <- P1 * N
    r2_all <- .r.squared.all(Y1, Y1hat)
    sigma <- if (method == "EU") sqrt(objfunc / n.valid) else NA_real_
    mae <- mean(abs(Y1 - Y1hat))
  }
  r.squared          <- r2_all$r.squared
  r.squared.uncentered     <- r2_all$r.squared.uncentered
  r.squared.centered <- r2_all$r.squared.centered

  if (print.trace) {
    msg <- sprintf("  Done: %d iterations, %.1f sec, R2 = %.4f", niter, diff.time, r.squared)
    if (n.missing > 0) msg <- paste0(msg, sprintf(", missing=%d", n.missing))
    message(msg)
  }

  # --- Soft/hard clustering (cf. nmfkc B.prob / B.cluster) ---
  H <- C %*% X2 %*% Y2   # Q x N encoding
  eps_bp <- 1e-16
  B.prob <- t( t(H) / (colSums(H) + eps_bp) )   # column-normalized
  B.cluster <- apply(B.prob, 2, which.max)
  B.cluster[colSums(H) == 0] <- NA

  result <- list(
    call = cl,
    X1 = X1,
    C = C,
    X2 = X2,
    Y1hat = Y1hat,
    H = H,
    B.prob = B.prob,
    B.cluster = B.cluster,
    rank = c(Q = Q, R = R),
    dims = c(P1 = P1, P2 = P2, N = N),
    method = method,
    objfunc = objfunc,
    objfunc.iter = objfunc.iter,
    r.squared          = r.squared,
    r.squared.uncentered     = r.squared.uncentered,
    r.squared.centered = r.squared.centered,
    sigma = sigma,
    mae = mae,
    niter = niter,
    iter = niter,          # house-style alias (matches nmfre/nmf.sem/nmfkc.net)
    runtime = diff.time,
    n.missing = n.missing,
    n.total = P1 * N
  )
  class(result) <- c("nmf.rrr", "nmfae", "nmf")
  return(result)
}

#' @title Statistical Inference for NMF-AE Parameter Matrix
#' @description
#' Performs post-estimation inference for \eqn{\Theta} in the three-layer NMF model
#' \eqn{Y_1 \approx X_1 \Theta X_2 Y_2}, conditional on \eqn{(\hat{X}_1, \hat{X}_2)}.
#' Uses sandwich covariance estimation and one-step wild bootstrap with
#' non-negative projection.
#'
#' @param object An object of class \code{"nmfae"} returned by \code{\link{nmfae}}.
#' @param Y1 Output matrix \eqn{Y_1} (P1 x N). Must match the data used in \code{nmfae()}.
#' @param Y2 Input matrix \eqn{Y_2} (P2 x N). Default is \code{Y1} (autoencoder).
#' @param wild.bootstrap Logical. If \code{TRUE} (default), performs wild bootstrap
#'   for bootstrap SE and confidence intervals. If \code{FALSE}, only sandwich SE
#'   and z-test p-values are computed (faster).
#' @param ... Additional arguments:
#'   \describe{
#'     \item{\code{wild.B}}{Number of bootstrap replicates. Default is 500.}
#'     \item{\code{wild.seed}}{Seed for bootstrap. Default is 123.}
#'     \item{\code{wild.level}}{Confidence level for bootstrap CI. Default is 0.95.}
#'     \item{\code{sandwich}}{Logical. Use sandwich covariance. Default is \code{TRUE}.}
#'     \item{\code{C.p.side}}{P-value type: \code{"one.sided"} (default) or \code{"two.sided"}.}
#'     \item{\code{cov.ridge}}{Ridge stabilization for information matrix inversion. Default is 1e-8.}
#'     \item{\code{print.trace}}{Logical. If \code{TRUE}, prints progress. Default is \code{FALSE}.}
#'   }
#'
#' @return An object of class \code{c("nmfae.inference", "nmfae")}, inheriting all
#' components from the input \code{object}, with additional inference components:
#' \item{sigma2.used}{Estimated \eqn{\sigma^2} used for inference.}
#' \item{C.se}{Sandwich standard errors for \eqn{\Theta} (Q x R matrix).}
#' \item{C.se.boot}{Bootstrap standard errors for \eqn{\Theta} (Q x R matrix).}
#' \item{C.ci.lower}{Lower CI bounds for \eqn{\Theta} (Q x R matrix).}
#' \item{C.ci.upper}{Upper CI bounds for \eqn{\Theta} (Q x R matrix).}
#' \item{coefficients}{Data frame with Estimate, SE, BSE, z, p-value for each element of \eqn{\Theta}.}
#' \item{C.p.side}{P-value type used.}
#'
#' @section Lifecycle:
#' This function is \strong{experimental}. The interface may change in
#' future versions; details are to be described in an upcoming paper.
#'
#' @seealso \code{\link{nmfae}}, \code{\link{summary.nmfae.inference}}
#' @export
#' @examples
#' Y <- matrix(c(1,0,1,0, 0,1,0,1, 1,1,0,0), nrow=3, byrow=TRUE)
#' res <- nmf.rrr(Y, rank1=2, rank2=2)
#' res2 <- nmf.rrr.inference(res, Y)
#' summary(res2)
#'
nmf.rrr.inference <- function(object, Y1, Y2 = Y1,
                            wild.bootstrap = TRUE, ...) {
  if (!inherits(object, "nmfae")) stop("object must be of class 'nmfae'")

  extra_args <- list(...)
  wild.B     <- if (!is.null(extra_args$wild.B))     extra_args$wild.B     else 500
  wild.seed  <- if (!is.null(extra_args$wild.seed))  extra_args$wild.seed  else 123
  wild.level <- if (!is.null(extra_args$wild.level)) extra_args$wild.level else 0.95
  sandwich   <- if (!is.null(extra_args$sandwich))   extra_args$sandwich   else TRUE
  C.p.side   <- if (!is.null(extra_args$C.p.side))   extra_args$C.p.side   else "one.sided"
  cov.ridge  <- if (!is.null(extra_args$cov.ridge))  extra_args$cov.ridge  else 1e-8
  print.trace <- if (!is.null(extra_args$print.trace)) extra_args$print.trace else FALSE

  X1 <- object$X1
  C  <- object$C
  X2 <- object$X2
  Y1hat <- object$Y1hat
  Q  <- object$rank["Q"]
  R  <- object$rank["R"]
  P1 <- nrow(Y1)
  N  <- ncol(Y1)

  Z   <- X2 %*% Y2                        # R x N
  R_C <- Y1 - Y1hat                       # P1 x N  residuals

  # sigma2 estimate (denominator: PN - QR)
  denom <- max(P1 * N - Q * R, 1)
  sigma2.used <- sum(R_C^2) / denom

  # Information matrix: I = sigma^{-2} (ZZ' x X1'X1)
  X1tX1 <- crossprod(X1)                  # Q x Q
  ZZt   <- tcrossprod(Z)                  # R x R
  Info_core <- kronecker(ZZt, X1tX1)      # QR x QR
  Info <- Info_core / max(sigma2.used, 1e-12)
  Info <- Info + diag(cov.ridge, nrow(Info))

  Hinv <- tryCatch(solve(Info), error = function(e) {
    if (requireNamespace("MASS", quietly = TRUE)) MASS::ginv(Info)
    else stop("Information matrix singular; install MASS package.")
  })

  # Sandwich covariance: V = Hinv J Hinv
  V_sand <- NULL
  if (isTRUE(sandwich)) {
    X1t <- t(X1)
    J <- matrix(0, Q * R, Q * R)
    for (n in 1:N) {
      z_n <- Z[, n, drop = FALSE]
      r_n <- R_C[, n, drop = FALSE]
      g_n <- X1t %*% r_n
      S_n <- -(g_n %*% t(z_n)) / max(sigma2.used, 1e-12)
      s_n <- as.vector(S_n)
      J <- J + tcrossprod(s_n)
    }
    if (N > 1) J <- (N / (N - 1)) * J     # CR1 correction
    V_sand <- Hinv %*% J %*% Hinv
  }

  C.vec.cov <- if (!is.null(V_sand)) V_sand else Hinv

  # Sandwich SE
  se_vec <- sqrt(pmax(diag(C.vec.cov), 0))
  C.se <- matrix(se_vec, nrow = Q, ncol = R, byrow = FALSE)

  # ---- Wild bootstrap (one-step Newton) ----
  C.se.boot <- NULL
  C.ci.lower <- NULL
  C.ci.upper <- NULL

  if (isTRUE(wild.bootstrap)) {
    set.seed(wild.seed)
    X1t <- t(X1)
    score_mat <- matrix(0, Q * R, N)
    for (n in 1:N) {
      z_n <- Z[, n, drop = FALSE]
      r_n <- R_C[, n, drop = FALSE]
      g_n <- X1t %*% r_n
      G_n <- -(g_n %*% t(z_n)) / max(sigma2.used, 1e-12)
      score_mat[, n] <- as.vector(G_n)
    }

    C_boot <- .boot.onestep(as.vector(C), score_mat, Hinv, wild.B,
                            dist = "exp", seed = wild.seed, project = TRUE)

    # Bootstrap SE
    sd_vec <- apply(C_boot, 1, stats::sd, na.rm = TRUE)
    C.se.boot <- matrix(sd_vec, nrow = Q, ncol = R, byrow = FALSE)

    # Bootstrap CI
    alpha <- 1 - wild.level
    lo <- apply(C_boot, 1, stats::quantile, probs = alpha / 2, na.rm = TRUE, names = FALSE)
    hi <- apply(C_boot, 1, stats::quantile, probs = 1 - alpha / 2, na.rm = TRUE, names = FALSE)
    C.ci.lower <- matrix(lo, nrow = Q, ncol = R, byrow = FALSE)
    C.ci.upper <- matrix(hi, nrow = Q, ncol = R, byrow = FALSE)
  }

  # ---- Coefficients table ----
  Estimate <- as.vector(C)
  SE <- as.vector(C.se)
  BSE <- if (!is.null(C.se.boot)) as.vector(C.se.boot) else rep(NA_real_, length(Estimate))
  z_value <- ifelse(SE > 0, Estimate / SE, NA_real_)

  if (C.p.side == "one.sided") {
    p_value <- ifelse(is.finite(z_value), stats::pnorm(z_value, lower.tail = FALSE), NA_real_)
  } else {
    p_value <- ifelse(is.finite(z_value), 1 - stats::pchisq(z_value^2, df = 1), NA_real_)
  }

  # Row/column labels for C
  rlabs <- if (!is.null(rownames(C))) rownames(C) else paste0("Resp", 1:Q)
  clabs <- if (!is.null(colnames(C))) colnames(C) else paste0("Cov", 1:R)

  coefficients <- data.frame(
    Basis    = rep(rlabs, times = R),
    Covariate = rep(clabs, each = Q),
    Estimate = Estimate,
    SE       = SE,
    BSE      = BSE,
    z_value  = z_value,
    p_value  = p_value,
    CI_low   = if (!is.null(C.ci.lower)) as.vector(C.ci.lower) else NA_real_,
    CI_high  = if (!is.null(C.ci.upper)) as.vector(C.ci.upper) else NA_real_,
    row.names = NULL, stringsAsFactors = FALSE
  )

  if (print.trace) {
    if (isTRUE(wild.bootstrap)) {
      message("  Inference: sandwich SE + wild bootstrap done.")
    } else {
      message("  Inference: sandwich SE done (wild bootstrap skipped).")
    }
  }

  # Add inference fields to the object
  object$sigma2.used  <- sigma2.used
  object$C.se         <- C.se
  object$C.se.boot    <- C.se.boot
  object$C.ci.lower   <- C.ci.lower
  object$C.ci.upper   <- C.ci.upper
  object$coefficients <- coefficients
  object$C.p.side     <- C.p.side
  class(object) <- c("nmfae.inference", "nmf.inference", "nmfae", "nmf")
  return(object)
}

#' @title Rename decoder and encoder bases
#' @description
#' Assigns user-specified names to the decoder (X1 columns) and encoder
#' (X2 rows) bases of an \code{nmfae} object.  The names propagate to
#' \eqn{\Theta}, the coefficients table, and all downstream displays
#' such as \code{summary}, \code{nmfae.DOT}, and \code{nmfae.heatmap}.
#'
#' @param x An object of class \code{"nmfae"} returned by \code{\link{nmfae}}.
#' @param X1.colnames Character vector of length \eqn{Q} for decoder bases
#'   (columns of \eqn{X_1} / rows of \eqn{\Theta}).  If \code{NULL}
#'   (default), the decoder names are left unchanged.
#' @param X2.rownames Character vector of length \eqn{R} for encoder bases
#'   (rows of \eqn{X_2} / columns of \eqn{\Theta}).  If \code{NULL}
#'   (default), the encoder names are left unchanged.
#'
#' @return A modified copy of \code{x} with updated names.
#' @examples
#' \donttest{
#' set.seed(1)
#' Y <- matrix(runif(15), nrow = 3)
#' res <- nmf.rrr(Y, rank1 = 2, rank2 = 2)
#' res <- nmf.rrr.rename(res,
#'   X1.colnames = c("Resp1", "Resp2"),
#'   X2.rownames = c("Cov1", "Cov2"))
#' summary(res)
#' }
#' @seealso \code{\link{nmfae}}
#' @export
nmf.rrr.rename <- function(x, X1.colnames = NULL, X2.rownames = NULL) {
  if (!is.null(X1.colnames)) {
    Q <- ncol(x$X1)
    if (length(X1.colnames) != Q) stop("X1.colnames must have length ", Q)
    colnames(x$X1) <- X1.colnames
    rownames(x$C)  <- X1.colnames
    if (!is.null(x$coefficients)) {
      old <- paste0("Resp", 1:Q)
      for (k in seq_len(Q))
        x$coefficients$Basis[x$coefficients$Basis == old[k]] <- X1.colnames[k]
    }
  }
  if (!is.null(X2.rownames)) {
    R <- nrow(x$X2)
    if (length(X2.rownames) != R) stop("X2.rownames must have length ", R)
    rownames(x$X2) <- X2.rownames
    colnames(x$C)  <- X2.rownames
    if (!is.null(x$coefficients)) {
      old <- paste0("Cov", 1:R)
      for (k in seq_len(R))
        x$coefficients$Covariate[x$coefficients$Covariate == old[k]] <- X2.rownames[k]
    }
  }
  x
}

#' \code{plot.nmfae} displays the convergence trajectory of the objective function
#' across iterations. The title shows the achieved \eqn{R^2}.
#'
#' @param x An object of class \code{"nmfae"} returned by \code{\link{nmfae}}.
#' @param ... Additional graphical parameters passed to \code{plot}.
#'
#' @return Invisible \code{NULL}. Called for its side effect (plot).
#' @seealso \code{\link{nmfae}}, \code{\link{nmfae.heatmap}}
#' @examples
#' \donttest{
#' set.seed(1)
#' Y <- matrix(runif(20), nrow = 4)
#' res <- nmf.rrr(Y, rank1 = 2)
#' plot(res)
#' }
#' @export
plot.nmfae <- function(x, ...) {
  extra_args <- list(...)
  args <- list(x = x$objfunc.iter)
  if (is.null(extra_args$main))
    args$main <- paste0("R2 = ", round(x$r.squared, 3))
  if (is.null(extra_args$xlab)) args$xlab <- "iter"
  if (is.null(extra_args$ylab)) args$ylab <- "objfunc"
  if (is.null(extra_args$type)) args$type <- "l"
  all_args <- c(args, extra_args)
  do.call("plot", all_args)
  invisible(NULL)
}

#' @title Summary method for nmfae objects
#' @keywords internal
#' @description
#' \code{summary.nmfae} produces a summary of a fitted NMF-AE model,
#' including dimensions, convergence status, goodness-of-fit statistics,
#' and structure diagnostics (sparsity of factor matrices).
#'
#' @param object An object of class \code{"nmfae"} returned by \code{\link{nmfae}}.
#' @param ... Additional arguments (currently unused).
#'
#' @return An object of class \code{"summary.nmfae"}, a list with components:
#' \item{call}{The matched call.}
#' \item{dims}{Named vector \code{c(P1, P2, N)}.}
#' \item{Q}{Decoder rank.}
#' \item{R}{Encoder rank.}
#' \item{n.params}{Total number of parameters (P1*Q + Q*R + R*P2).}
#' \item{autoencoder}{Logical; TRUE if P1 == P2 and Y1 was used as Y2.}
#' \item{niter}{Number of iterations.}
#' \item{runtime}{Elapsed time.}
#' \item{objfunc}{Final objective value.}
#' \item{r.squared}{R-squared.}
#' \item{sigma}{Residual standard error (RMSE).}
#' \item{mae}{Mean absolute error.}
#' \item{n.missing}{Number of missing elements.}
#' \item{prop.missing}{Percentage of missing elements.}
#' \item{X1.sparsity}{Proportion of near-zero elements in X1.}
#' \item{C.sparsity}{Proportion of near-zero elements in C.}
#' \item{X2.sparsity}{Proportion of near-zero elements in X2.}
#'
#' @seealso \code{\link{nmfae}}, \code{\link{print.summary.nmfae}}
#' @export
summary.nmfae <- function(object, ...) {
  ans <- list()
  ans$call <- object$call
  ans$dims <- object$dims
  Q <- object$rank["Q"]
  R <- object$rank["R"]
  ans$Q <- Q
  ans$R <- R

  P1 <- object$dims["P1"]
  P2 <- object$dims["P2"]
  N  <- object$dims["N"]
  ans$n.params <- P1 * Q + Q * R + R * P2

  # Autoencoder detection
  ans$autoencoder <- (P1 == P2) &&
    !is.null(object$call$Y2) == FALSE
  # More robust: check if Y2 argument was not explicitly provided
  if (is.null(object$call$Y2)) {
    ans$autoencoder <- TRUE
  } else {
    ans$autoencoder <- FALSE
  }

  ans$niter <- object$niter
  ans$runtime <- object$runtime

  ans$objfunc <- object$objfunc
  ans$r.squared          <- object$r.squared
  ans$r.squared.uncentered     <- object$r.squared.uncentered
  ans$r.squared.centered <- object$r.squared.centered
  ans$sigma <- object$sigma
  ans$mae <- object$mae
  ## Effective rank of the latent encoding H (Q x N).
  ans$effective.rank <- if (!is.null(object$H)) .effective.rank(object$H) else NA_real_
  ans$rank <- if (!is.null(object$rank)) object$rank[1] else
              if (!is.null(object$H)) nrow(object$H) else NA

  # Missing values
  ans$n.missing <- object$n.missing
  if (!is.null(object$n.missing) && !is.null(object$n.total)) {
    ans$prop.missing <- object$n.missing / object$n.total * 100
  } else {
    ans$prop.missing <- 0
  }

  # Sparsity diagnostics
  if (!is.null(object$X1) && is.matrix(object$X1)) {
    ans$X1.sparsity <- mean(object$X1 < 1e-4)
  }
  if (!is.null(object$C) && is.matrix(object$C)) {
    ans$C.sparsity <- mean(object$C < 1e-4)
  }
  if (!is.null(object$X2) && is.matrix(object$X2)) {
    ans$X2.sparsity <- mean(object$X2 < 1e-4)
  }

  # Inference
  ans$coefficients <- object$coefficients
  ans$C.p.side <- object$C.p.side

  class(ans) <- "summary.nmfae"
  return(ans)
}

#' @title Print method for summary.nmfae objects
#' @keywords internal
#' @description
#' Prints a formatted summary of an NMF-AE model fit.
#'
#' @param x An object of class \code{"summary.nmfae"}.
#' @param digits Minimum number of significant digits to be used.
#' @param max.coef Maximum number of coefficient rows to display. If the table
#'   has more rows, only significant rows (p < 0.05) are shown. Default is 20.
#' @param by Character; grouping order of the coefficients table.
#'   \code{"covariate"} (default) lists all bases within each covariate
#'   (1-1, 1-2, ...); \code{"basis"} lists all covariates within each basis
#'   (1-1, 2-1, ...).
#' @param ... Additional arguments (currently unused).
#' @return Called for its side effect (printing). Returns \code{x} invisibly.
#' @seealso \code{\link{summary.nmfae}}
#' @export
print.summary.nmfae <- function(x, digits = max(3L, getOption("digits") - 3L),
                                max.coef = 20, by = c("covariate", "basis"), ...) {
  by <- match.arg(by)
  cat("\nCall:\n",
      paste(deparse(x$call), sep = "\n", collapse = "\n"),
      "\n\n", sep = "")

  P1 <- x$dims["P1"]; P2 <- x$dims["P2"]; N <- x$dims["N"]
  Q <- x$Q; R <- x$R

  cat("Dimensions:\n")
  type_str <- if (x$autoencoder) "  (autoencoder)" else "  (heteroencoder)"
  cat("  Y1:             ", sprintf("%d x %d", P1, N), "\n")
  cat("  Y2:             ", sprintf("%d x %d", P2, N), type_str, "\n")
  cat("  Response rank Q:", Q, "\n")
  cat("  Covariate rank R:", R, "\n")
  cat("  Parameters:     ",
      sprintf("X1(%dx%d) + C(%dx%d) + X2(%dx%d) = %d",
              P1, Q, Q, R, R, P2, x$n.params), "\n")

  cat("\nConvergence:\n")
  cat("  Iterations:     ", x$niter, "\n")
  cat("  Runtime:        ", sprintf("%.1f secs", x$runtime), "\n")

  if (x$n.missing > 0) {
    cat("  Missing:        ", x$n.missing,
        sprintf("(%.1f%%)", x$prop.missing), "\n")
  }

  .print.fit.statistics(x, header = "Goodness of fit:", digits = digits)

  .print.structure.diagnostics(
    sparsity = c("Response (X1)" = x$X1.sparsity,
                 "Bottleneck (C)" = x$C.sparsity,
                 "Covariate (X2)" = x$X2.sparsity))

  # Coefficients table (inference)
  if (!is.null(x$coefficients) && is.data.frame(x$coefficients)) {
    cf <- x$coefficients
    cf <- cf[.coef.order.by(cf, by), , drop = FALSE]   # grouping order (by)
    n_total <- nrow(cf)
    rnames <- paste0(cf$Covariate, ":", cf$Basis)

    # Determine which rows to display
    if (n_total <= max.coef) {
      show_idx <- seq_len(n_total)
      truncated <- FALSE
    } else {
      sig_idx <- which(cf$p_value < 0.05)
      if (length(sig_idx) == 0) {
        show_idx <- seq_len(min(max.coef, n_total))
        truncated <- TRUE
      } else if (length(sig_idx) <= max.coef) {
        show_idx <- sig_idx
        truncated <- FALSE
      } else {
        show_idx <- sig_idx[seq_len(max.coef)]
        truncated <- TRUE
      }
    }

    n_sig <- sum(cf$p_value < 0.05, na.rm = TRUE)
    cat(sprintf("\nCoefficients (conditional on X1, X2): %d total, %d significant\n",
                n_total, n_sig))
    if (n_total > max.coef) {
      cat(sprintf("  (showing %d significant rows; use res$coefficients for full table)\n",
                  length(show_idx)))
    }

    p_side <- if (!is.null(x$C.p.side)) x$C.p.side else "one.sided"
    p_header <- if (p_side == "one.sided") "Pr(>z)" else "Pr(>|z|)"

    sig_stars <- function(p) {
      ifelse(!is.finite(p), " ",
        ifelse(p < 0.001, "***",
          ifelse(p < 0.01, "**",
            ifelse(p < 0.05, "*",
              ifelse(p < 0.1, ".", " ")))))
    }
    format_pval <- function(p) {
      ifelse(!is.finite(p), "      NA",
        ifelse(p < 2.2e-16, "  <2e-16",
          formatC(p, format = "g", digits = 4, width = 8)))
    }

    est <- formatC(cf$Estimate[show_idx], format = "f", digits = 3, width = 9)
    se  <- formatC(cf$SE[show_idx], format = "f", digits = 3, width = 10)
    bse <- formatC(cf$BSE[show_idx], format = "f", digits = 3, width = 6)
    zv  <- formatC(cf$z_value[show_idx], format = "f", digits = 2, width = 7)
    pv_str <- format_pval(cf$p_value[show_idx])
    stars <- sig_stars(cf$p_value[show_idx])
    show_names <- rnames[show_idx]

    max_lw <- max(nchar(show_names))
    hdr <- sprintf("%s %s %s %s %s %s",
                   formatC("Estimate", width = 9),
                   formatC("Std. Error", width = 10),
                   formatC("(Boot)", width = 6),
                   formatC("z value", width = 7),
                   formatC(p_header, width = 8), "")
    cat(sprintf("%s %s\n", formatC("Cov:Resp", width = max_lw), hdr))
    for (i in seq_along(show_names)) {
      cat(sprintf("%s %s %s %s %s %s %s\n",
                  formatC(show_names[i], width = max_lw),
                  est[i], se[i], bse[i], zv[i], pv_str[i], stars[i]))
    }
    cat("---\n")
    cat("Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1\n")
  }

  cat("\n")
  invisible(x)
}

#' @title Summary method for nmfae.inference objects
#' @keywords internal
#' @description
#' Produces a summary of a fitted NMF-AE model with inference results,
#' including the coefficients table for \eqn{\Theta}.
#'
#' @param object An object of class \code{"nmfae.inference"}.
#' @param ... Additional arguments (currently unused).
#' @return An object of class \code{"summary.nmfae.inference"}.
#' @seealso \code{\link{nmfae.inference}}, \code{\link{summary.nmfae}}
#' @export
summary.nmfae.inference <- function(object, ...) {
  ans <- summary.nmfae(object, ...)
  class(ans) <- "summary.nmfae.inference"
  return(ans)
}

#' @title Print method for summary.nmfae.inference objects
#' @keywords internal
#' @description
#' Prints a formatted summary including the coefficients table.
#' @param x An object of class \code{"summary.nmfae.inference"}.
#' @param digits Minimum number of significant digits.
#' @param max.coef Maximum coefficient rows to display. Default is 20.
#' @param by Character; grouping order of the coefficients table.
#'   \code{"covariate"} (default) lists all bases within each covariate
#'   (1-1, 1-2, ...); \code{"basis"} lists all covariates within each basis
#'   (1-1, 2-1, ...).
#' @param ... Additional arguments (currently unused).
#' @return Called for its side effect (printing). Returns \code{x} invisibly.
#' @seealso \code{\link{summary.nmfae.inference}}
#' @export
print.summary.nmfae.inference <- function(x, digits = max(3L, getOption("digits") - 3L),
                                          max.coef = 20, by = c("covariate", "basis"), ...) {
  by <- match.arg(by)
  print.summary.nmfae(x, digits = digits, max.coef = max.coef, by = by, ...)
}



#' @title Heatmap visualization of nmfae factor matrices
#' @description
#' \code{nmfae.heatmap} displays the three factor matrices \eqn{X_1}, \eqn{\Theta},
#' and \eqn{X_2} as side-by-side heatmaps. This provides an alternative to DOT graph
#' visualization, especially when \eqn{Y_2} has many variables (e.g., kernel matrix).
#'
#' @param x An object of class \code{"nmfae"} returned by \code{\link{nmfae}}.
#' @param Y1.label Character vector of output variable names (rows of \eqn{X_1}).
#' @param X1.label Character vector of decoder basis labels (columns of \eqn{X_1}).
#' @param X2.label Character vector of encoder basis labels (rows of \eqn{X_2}).
#' @param Y2.label Character vector of input variable names (columns of \eqn{X_2}).
#' @param palette Color palette vector. Default is white-orange-red (64 colors).
#' @param ... Not used.
#'
#' @return Invisible \code{NULL}. Called for its side effect (plot).
#'
#' @section Lifecycle:
#' This function is \strong{experimental}. The interface may change in
#' future versions; details are to be described in an upcoming paper.
#'
#' @seealso \code{\link{nmfae}}, \code{\link{plot.nmfae}}, \code{\link{nmfae.DOT}}
#' @examples
#' \donttest{
#' set.seed(1)
#' Y <- matrix(runif(20), nrow = 4)
#' res <- nmf.rrr(Y, rank1 = 2)
#' nmf.rrr.heatmap(res)
#' }
#' @export
nmf.rrr.heatmap <- function(x,
                          Y1.label = NULL, X1.label = NULL,
                          X2.label = NULL, Y2.label = NULL,
                          palette = NULL, ...) {
  X1 <- x$X1
  C_mat <- x$C
  X2 <- x$X2
  P1 <- nrow(X1); Q <- ncol(X1)
  R <- nrow(X2); P2 <- ncol(X2)

  if (is.null(palette))
    palette <- grDevices::colorRampPalette(c("white", "orange", "red"))(64)

  # Default labels: use dimnames from the object, then fallback to prefix
  resolve_lab <- function(lab, dimnames_default, expected_len, prefix) {
    if (!is.null(lab) && length(lab) != expected_len) {
      warning(sprintf("Label length (%d) does not match dimension (%d). Labels ignored.",
                      length(lab), expected_len))
      lab <- NULL
    }
    if (is.null(lab)) lab <- dimnames_default
    if (is.null(lab)) lab <- paste0(prefix, 1:expected_len)
    lab
  }
  Y1.label <- resolve_lab(Y1.label, rownames(X1), P1, "Y1.")
  X1.label <- resolve_lab(X1.label, colnames(X1), Q,  "Resp")
  X2.label <- resolve_lab(X2.label, rownames(X2), R,  "Cov")
  Y2.label <- resolve_lab(Y2.label, colnames(X2), P2, "Y2.")

  # Helper: plot a matrix as heatmap (row 1 at top)
  plot_mat <- function(mat, main, row.lab, col.lab, mar) {
    opar <- graphics::par(mar = mar)
    on.exit(graphics::par(opar))
    nr <- nrow(mat); nc <- ncol(mat)
    graphics::image(1:nc, 1:nr, t(mat[nr:1, , drop = FALSE]),
                    col = palette, axes = FALSE,
                    xlab = "", ylab = "", main = main)
    graphics::box()
    if (!is.null(col.lab)) {
      cex_col <- min(0.8, 20 / length(col.lab))
      graphics::axis(1, at = 1:nc, labels = col.lab, las = 2, cex.axis = cex_col)
    }
    if (!is.null(row.lab)) {
      cex_row <- min(0.8, 20 / length(row.lab))
      graphics::axis(2, at = 1:nr, labels = rev(row.lab), las = 1, cex.axis = cex_row)
    }
  }

  opar <- graphics::par(mfrow = c(1, 3))
  on.exit(graphics::par(opar))

  plot_mat(X1, sprintf("X1 (%d x %d)", P1, Q), Y1.label, X1.label,
           mar = c(5, 6, 3, 1))
  plot_mat(C_mat, sprintf("C (%d x %d)", Q, R), X1.label, X2.label,
           mar = c(5, 4, 3, 1))
  plot_mat(X2, sprintf("X2 (%d x %d)", R, P2), X2.label, Y2.label,
           mar = c(5, 4, 3, 1))

  invisible(NULL)
}

#' @title Predict method for nmfae objects
#' @keywords internal
#' @description
#' \code{predict.nmfae} computes fitted or predicted values from a three-layer NMF model.
#' Without \code{newY2}, returns the in-sample fitted values \eqn{X_1 \Theta X_2 Y_2}.
#' With \code{newY2}, computes out-of-sample predictions \eqn{X_1 \Theta X_2 \cdot \mathrm{newY2}}.
#'
#' When \code{type = "class"}, each column is classified to the row with the
#' maximum predicted value (useful when \eqn{Y_1} is a one-hot class matrix
#' from \code{\link{nmfkc.class}}).
#'
#' If \code{Y1} (actual values) is provided, it is stored as an attribute so that
#' \code{plot.predict.nmfae} can produce an observed-vs-predicted scatter plot
#' (for \code{type = "response"}) or a confusion matrix heatmap
#' (for \code{type = "class"}).
#'
#' @param object An object of class \code{"nmfae"} returned by \code{\link{nmfae}}.
#' @param newY2 Optional new input matrix (P2 x M) for prediction.
#'   If \code{NULL}, returns in-sample fitted values.
#' @param Y1 Optional actual output matrix for comparison plotting.
#' @param type Character. \code{"response"} (default) returns the predicted matrix.
#'   \code{"class"} returns a factor of predicted class labels (row with max value).
#' @param ... Not used.
#'
#' @return For \code{type = "response"}: a matrix of class \code{"predict.nmfae"}.
#'   For \code{type = "class"}: a factor of class \code{"predict.nmfae"} with
#'   predicted class labels. If \code{Y1} was provided, actual classes are stored
#'   in \code{attr(result, "actual")}.
#' @seealso \code{\link{nmfae}}, \code{\link{plot.predict.nmfae}},
#'   \code{\link{nmfkc.class}}
#' @examples
#' \donttest{
#' set.seed(1)
#' Y <- matrix(runif(20), nrow = 4)
#' res <- nmf.rrr(Y, rank1 = 2)
#' pred <- predict(res)
#' }
#' @export
predict.nmfae <- function(object, newY2 = NULL, Y1 = NULL,
                          type = c("response", "class"), ...) {
  type <- match.arg(type)
  if (is.null(newY2)) pred_mat <- object$Y1hat
  else pred_mat <- object$X1 %*% object$C %*% object$X2 %*% as.matrix(newY2)

  if (type == "response") {
    result <- pred_mat
    if (!is.null(Y1)) attr(result, "Y1") <- Y1
    class(result) <- c("predict.nmfae", class(result))
  } else {
    # Class prediction: row with max value per column
    labels <- rownames(pred_mat)
    if (is.null(labels)) labels <- paste0("C", seq_len(nrow(pred_mat)))
    pred_class <- factor(labels[apply(pred_mat, 2, which.max)], levels = labels)
    result <- pred_class
    if (!is.null(Y1)) {
      Y1 <- as.matrix(Y1)
      act_labels <- rownames(Y1)
      if (is.null(act_labels)) act_labels <- labels
      actual <- factor(act_labels[apply(Y1, 2, which.max)], levels = act_labels)
      attr(result, "actual") <- actual
    }
    class(result) <- c("predict.nmfae", class(result))
    attr(result, "type") <- "class"
  }
  result
}

#' @title Plot method for predict.nmfae objects
#' @keywords internal
#' @description
#' For \code{type = "response"}: if actual values \eqn{Y_1} were stored,
#' displays an observed-vs-predicted scatter plot with \eqn{R^2} in the title.
#' Otherwise, displays the predicted matrix as a heatmap.
#'
#' For \code{type = "class"}: if actual classes were stored, displays a
#' confusion matrix heatmap with accuracy (ACC) in the title.
#'
#' @param x An object of class \code{"predict.nmfae"} returned by \code{\link{predict.nmfae}}.
#' @param ... Additional graphical parameters passed to \code{plot} or \code{image}.
#'
#' @return Invisible \code{NULL}. Called for its side effect (plot).
#' @seealso \code{\link{predict.nmfae}}
#' @examples
#' \donttest{
#' set.seed(1)
#' Y <- matrix(runif(20), nrow = 4)
#' res <- nmf.rrr(Y, rank1 = 2)
#' pred <- predict(res)
#' plot(pred)
#' }
#' @export
plot.predict.nmfae <- function(x, ...) {
  extra_args <- list(...)
  pred_type <- attr(x, "type")

  if (!is.null(pred_type) && pred_type == "class") {
    # --- Confusion matrix heatmap ---
    actual <- attr(x, "actual")
    if (is.null(actual)) {
      message("No actual classes stored. Use Y1 argument in predict().")
      return(invisible(NULL))
    }
    pred_class <- factor(x, levels = levels(actual))
    cm <- table(Actual = actual, Predicted = pred_class)
    acc <- sum(diag(cm)) / sum(cm)

    # Heatmap of confusion matrix
    mat <- as.matrix(cm)
    nr <- nrow(mat); nc <- ncol(mat)
    pal <- grDevices::colorRampPalette(c("white", "orange", "red"))(64)
    opar <- graphics::par(mar = c(5, 5, 3, 1))
    on.exit(graphics::par(opar))
    graphics::image(1:nc, 1:nr, t(mat[nr:1, , drop = FALSE]),
                    col = pal, axes = FALSE, xlab = "Predicted", ylab = "Actual")
    graphics::box()
    labs <- rownames(mat)
    graphics::axis(1, at = 1:nc, labels = colnames(mat), las = 2, cex.axis = 0.8)
    graphics::axis(2, at = 1:nr, labels = rev(labs), las = 1, cex.axis = 0.8)
    # Cell labels
    for (i in 1:nr) {
      for (j in 1:nc) {
        val <- mat[i, j]
        if (val > 0) {
          yi <- nr - i + 1
          graphics::text(j, yi, val, cex = 0.7,
                         col = ifelse(val > stats::median(mat), "white", "black"))
        }
      }
    }
    if (is.null(extra_args$main))
      graphics::title(main = sprintf("ACC = %.1f%%", acc * 100))
    else
      graphics::title(main = extra_args$main)

  } else {
    # --- Response type ---
    Y1 <- attr(x, "Y1")

    if (!is.null(Y1)) {
      # Scatter plot: observed vs predicted
      obs <- as.numeric(as.matrix(Y1))
      pred <- as.numeric(unclass(x))
      r2 <- stats::cor(obs, pred)^2
      args <- list(x = obs, y = pred)
      if (is.null(extra_args$main))
        args$main <- sprintf("R2 = %.3f", r2)
      if (is.null(extra_args$xlab)) args$xlab <- "Observed"
      if (is.null(extra_args$ylab)) args$ylab <- "Predicted"
      if (is.null(extra_args$pch))  args$pch <- 16
      if (is.null(extra_args$cex))  args$cex <- 0.8
      if (is.null(extra_args$col))  args$col <- grDevices::rgb(0, 0, 0, 0.4)
      all_args <- c(args, extra_args)
      do.call("plot", all_args)
      graphics::abline(0, 1, col = "red")
    } else {
      # Heatmap of predicted matrix
      mat <- unclass(x)
      nr <- nrow(mat); nc <- ncol(mat)
      pal <- grDevices::colorRampPalette(c("white", "orange", "red"))(64)
      args <- list(x = 1:nc, y = 1:nr, z = t(mat[nr:1, , drop = FALSE]),
                   col = pal, axes = FALSE, xlab = "Sample", ylab = "Variable")
      if (is.null(extra_args$main)) args$main <- "Predicted Y1"
      all_args <- c(args, extra_args)
      do.call("image", all_args)
      graphics::box()
    }
  }
  invisible(NULL)
}

#' @title Element-wise Cross-Validation for nmfae (Wold's CV)
#' @description
#' \code{nmfae.ecv} performs k-fold element-wise cross-validation by randomly
#' holding out individual elements of \eqn{Y_1}, assigning them a weight of 0
#' via \code{Y1.weights}, and evaluating the reconstruction error on those
#' held-out elements.
#'
#' This method (also known as Wold's CV) is suitable for determining the optimal
#' rank pair \eqn{(Q, R)} in three-layer NMF. Both \code{rank1} and \code{rank2} accept
#' vector inputs. When \code{rank2 = NULL} (default), \code{rank2} is set equal to \code{rank1}
#' and pairs are evaluated element-wise (i.e., \eqn{(Q_1, R_1), (Q_2, R_2), \dots}).
#' When \code{rank.encoder} is explicitly specified, all combinations of \code{rank} and \code{rank.encoder}
#' are evaluated via \code{expand.grid}.
#'
#' @param Y1 Output matrix \eqn{Y_1} (P1 x N).
#' @param Y2 Input matrix \eqn{Y_2} (P2 x N). Default is \code{Y1}.
#' @param rank1 Integer vector of response-basis ranks to evaluate. Default is \code{1:2}.
#' @param rank2 Integer vector of covariate-basis ranks to evaluate. Default is \code{NULL},
#'   which sets \code{rank2 = rank1} and evaluates element-wise pairs.
#' @param rank,rank.encoder Deprecated aliases of \code{rank1} / \code{rank2}
#'   (\code{Q} / \code{R} also accepted via \code{...}).
#'   When explicitly specified, all combinations with \code{rank} are evaluated.
#' @param ... Additional arguments passed to \code{\link{nmfae}} (e.g., \code{epsilon}, \code{maxit}).
#'   Also accepts: \code{nfolds} (number of folds, default 5; \code{div} also accepted),
#'   \code{seed} (integer seed, default 123).
#'   For backward compatibility, \code{Q} and \code{R} are accepted as aliases for
#'   \code{rank} and \code{rank.encoder}.
#'
#' @return A list with components:
#' \item{objfunc}{Named numeric vector of mean MSE for each (Q, R) pair.}
#' \item{sigma}{Named numeric vector of RMSE (square root of MSE) for each pair.}
#' \item{objfunc.fold}{Named list of per-fold MSE vectors for each pair.}
#' \item{folds}{List of length \code{div} containing the held-out element indices for each fold.}
#' \item{QR}{Data frame with columns \code{Q} and \code{R} listing the evaluated pairs.}
#'
#' @section Lifecycle:
#' This function is \strong{experimental}. The interface may change in
#' future versions; details are to be described in an upcoming paper.
#'
#' @seealso \code{\link{nmfae}}, \code{\link{nmfkc.ecv}}
#' @export
#' @examples
#' Y <- t(iris[1:30, 1:4])
#' # Default: rank2=NULL -> paired rank1=rank2
#' res <- nmf.rrr.ecv(Y, rank1 = 1:3, nfolds = 3, maxit = 500)
#' res$sigma
#' # Explicit rank.encoder: full grid
#' res2 <- nmf.rrr.ecv(Y, rank1 = 1:3, rank2 = 1:3, nfolds = 3, maxit = 500)
#' res2$sigma
#'
nmf.rrr.ecv <- function(Y1, Y2 = Y1, rank1 = 1:2, rank2 = NULL, ...,
                      rank = NULL, rank.encoder = NULL) {
  extra_ecv <- list(...)
  # rank1/rank2 = response/covariate basis ranks to sweep; legacy rank/rank.encoder/Q/R
  if (is.null(rank))          rank <- rank1
  if (!is.null(extra_ecv$Q))  rank <- extra_ecv$Q
  if (is.null(rank.encoder))  rank.encoder <- rank2
  if (!is.null(extra_ecv$R))  rank.encoder <- extra_ecv$R
  nfolds <- if (!is.null(extra_ecv$nfolds)) extra_ecv$nfolds else if (!is.null(extra_ecv$div)) extra_ecv$div else 5
  seed   <- if (!is.null(extra_ecv$seed))   extra_ecv$seed   else 123
  Q <- rank; R <- rank.encoder
  div <- nfolds

  Y1 <- as.matrix(Y1); Y2 <- as.matrix(Y2)
  P1 <- nrow(Y1); N <- ncol(Y1)

  # R=NULL -> paired with Q; R specified -> full grid
  if (is.null(R)) {
    QR <- data.frame(Q = Q, R = Q)
  } else {
    QR <- expand.grid(Q = Q, R = R)
  }
  num_pairs <- nrow(QR)

  # Create folds (element-wise on Y1; shared helper)
  folds <- .ecv.make.folds(Y1, div, seed)

  # Prepare result storage
  pair_labels <- sprintf("Q=%d,R=%d", QR$Q, QR$R)
  has_na <- any(is.na(Y1))

  message(sprintf("Element-wise CV: %d (Q,R) pairs, %d-fold, %d tasks...",
                  num_pairs, div, num_pairs * div))

  extra_args <- list(...)

  # Model-specific worker: mask fold k, refit at pair i, held-out loss
  run_one <- function(i, k) {
    test_idx <- folds[[k]]
    weights_train <- matrix(1, nrow = P1, ncol = N)
    if (has_na) weights_train[is.na(Y1)] <- 0
    weights_train[test_idx] <- 0
    fit <- suppressMessages(
      do.call(nmf.rrr, c(list(Y1 = Y1, Y2 = Y2, Q = QR$Q[i], R = QR$R[i],
                            Y1.weights = weights_train), extra_args))
    )
    mean((Y1[test_idx] - fit$Y1hat[test_idx])^2)
  }

  cv <- .ecv.run(pair_labels, div, run_one,
                 progress = function(i, o, s)
                   message(sprintf("  Q=%d, R=%d: MSE=%.6f, sigma=%.4f",
                                   QR$Q[i], QR$R[i], o, s)))

  result <- list(objfunc = cv$objfunc,
                 sigma = cv$sigma,
                 objfunc.fold = cv$objfunc.fold,
                 folds = folds,
                 QR = QR,
                 paired = is.null(R))
  class(result) <- "nmfae.ecv"
  return(result)
}


#' @title Rank selection for nmfae (paired rank, concise diagnostics)
#' @description
#' Fits \code{\link{nmfae}} with a \strong{paired} decoder/encoder rank
#' (\eqn{Q = R}) across a range of ranks and reports \code{r.squared},
#' the effective rank (of the latent encoding \eqn{H}), and the
#' element-wise CV error \code{sigma.ecv}, with the same concise plot as
#' \code{\link{nmfkc.rank}}.  For a full \eqn{(Q, R)} grid use
#' \code{\link{nmfae.ecv}} with \code{rank.encoder} and its heatmap.
#' @param Y1 Endogenous matrix (\eqn{P_1 \times N}).
#' @param Y2 Exogenous matrix; defaults to \code{Y1} (autoencoder).
#' @param rank1 Integer vector of (paired) ranks to evaluate (both bases use
#'   the same value). Legacy \code{Q} accepted via \code{...}.
#' @param rank Deprecated alias of \code{rank1}.
#' @param detail \code{"full"} (default) also runs element-wise CV
#'   (\code{sigma.ecv}); \code{"fast"} skips it (plots r.squared and
#'   eff.rank only, and recommends the R-squared elbow).
#' @param plot Logical; draw the diagnostics plot (default \code{TRUE}).
#' @param ... Passed on to \code{\link{nmfae}} and \code{\link{nmfae.ecv}}
#'   (e.g.\ \code{maxit}, \code{nfolds}, \code{seed}).
#' @return A list with \code{rank.best} and \code{criteria}
#'   (\code{rank}, \code{effective.rank}, \code{effective.rank.ratio},
#'   \code{r.squared}, \code{sigma.ecv}).
#' @seealso \code{\link{nmfae}}, \code{\link{nmfae.ecv}},
#'   \code{\link{nmfkc.rank}}
#' @references
#' Roy, O., & Vetterli, M. (2007).  The effective rank: A measure of
#' effective dimensionality.  \emph{Proc. EUSIPCO}, 606--610.
#' (\code{effective.rank})
#' Wold, S. (1978).  Cross-validatory estimation of the number of
#' components in factor and principal components models.
#' \emph{Technometrics}, 20(4), 397--405. (\code{sigma.ecv})
#' @export
nmf.rrr.rank <- function(Y1, Y2 = Y1, rank1 = 1:5, detail = c("full", "fast"),
                       plot = TRUE, ..., rank = NULL) {
  extra <- list(...)
  # sweep a single rank for both bases; legacy rank (formal) / Q (via ...)
  if (!is.null(rank))    rank1 <- rank
  if (!is.null(extra$Q)) rank1 <- extra$Q
  extra$Q <- NULL; extra$R <- NULL; extra$rank.encoder <- NULL
  detail <- match.arg(detail)
  Y1 <- as.matrix(Y1); Y2 <- as.matrix(Y2)
  rs <- numeric(length(rank1)); er <- numeric(length(rank1))
  for (i in seq_along(rank1)) {
    f <- suppressMessages(do.call(nmf.rrr, c(list(Y1, Y2, rank1 = rank1[i], rank2 = rank1[i],
                                                print.trace = FALSE), extra)))
    rs[i] <- f$r.squared
    er[i] <- .effective.rank(f$H)
  }
  ecv <- if (detail == "full")
    suppressMessages(do.call(nmf.rrr.ecv, c(list(Y1, Y2, rank1 = rank1), extra)))$sigma
    else rep(NA_real_, length(rank1))
  criteria <- data.frame(rank = rank1, effective.rank = er,
                         effective.rank.ratio = er / rank1,
                         r.squared = rs, sigma.ecv = as.numeric(ecv))
  .rank.finish(criteria, plot = plot, main = "nmfae rank selection (paired Q=R)")
}

#' @title Plot method for nmfae.ecv objects
#' @keywords internal
#' @description
#' Visualizes element-wise cross-validation results.
#' When \code{rank.encoder} was \code{NULL} (paired), a line plot of sigma vs rank is drawn.
#' When \code{rank.encoder} was explicitly specified (grid), a heatmap of sigma over the (rank, rank.encoder) grid is drawn.
#'
#' @param x An object of class \code{"nmfae.ecv"} returned by \code{\link{nmfae.ecv}}.
#' @param ... Additional graphical parameters (currently unused).
#'
#' @return Invisible \code{NULL}. Called for its side effect of producing a plot.
#' @seealso \code{\link{nmfae.ecv}}
#' @export
plot.nmfae.ecv <- function(x, ...) {
  Qs <- sort(unique(x$QR$Q))
  Rs <- sort(unique(x$QR$R))
  use_line <- x$paired || length(Qs) == 1 || length(Rs) == 1

  if (use_line) {
    # --- Line plot ---
    if (x$paired) {
      xvals <- x$QR$Q; xlab_str <- "Q = R"
    } else if (length(Qs) == 1) {
      xvals <- x$QR$R; xlab_str <- paste0("R (Q=", Qs, ")")
    } else {
      xvals <- x$QR$Q; xlab_str <- paste0("Q (R=", Rs, ")")
    }
    plot(xvals, x$sigma, type = "b", pch = 16,
         xlab = xlab_str, ylab = "sigma",
         main = "Element-wise CV",
         xaxt = "n")
    graphics::axis(1, at = xvals)
    best <- which.min(x$sigma)
    graphics::points(xvals[best], x$sigma[best], pch = 16, col = "red", cex = 1.5)
  } else {
    # --- Heatmap: sigma over (Q, R) grid ---
    Qs <- sort(unique(x$QR$Q))
    Rs <- sort(unique(x$QR$R))
    sigma_mat <- matrix(NA, length(Qs), length(Rs))
    for (i in 1:nrow(x$QR)) {
      qi <- which(Qs == x$QR$Q[i])
      ri <- which(Rs == x$QR$R[i])
      sigma_mat[qi, ri] <- x$sigma[i]
    }

    ncolors <- 64
    col_palette <- grDevices::hcl.colors(ncolors, "YlOrRd", rev = TRUE)

    opar <- graphics::par(mar = c(5, 5, 3, 8))
    on.exit(graphics::par(opar))

    graphics::image(Qs, Rs, sigma_mat, col = col_palette,
          xlab = "Q (decoder rank)", ylab = "R (encoder rank)",
          main = "Element-wise CV", axes = FALSE)
    graphics::axis(1, at = Qs)
    graphics::axis(2, at = Rs)
    graphics::box()

    # Cell values
    for (qi in seq_along(Qs)) {
      for (ri in seq_along(Rs)) {
        val <- sigma_mat[qi, ri]
        txt_col <- "black"
        graphics::text(Qs[qi], Rs[ri], sprintf("%.2f", val), cex = 0.65, col = txt_col)
      }
    }

    # Color bar — use cell step as unit for positioning
    old_par <- graphics::par(xpd = TRUE)
    on.exit(graphics::par(old_par), add = TRUE)
    q_step <- if (length(Qs) > 1) diff(Qs)[1] else 1
    r_step <- if (length(Rs) > 1) diff(Rs)[1] else 1
    bar_x <- max(Qs) + q_step * 0.7
    bar_w <- q_step * 0.3
    bar_y <- seq(min(Rs), max(Rs), length.out = ncolors + 1)
    for (i in 1:ncolors) {
      graphics::rect(bar_x, bar_y[i], bar_x + bar_w, bar_y[i + 1],
           col = col_palette[i], border = NA)
    }
    graphics::rect(bar_x, min(Rs), bar_x + bar_w, max(Rs))
    sigma_range <- range(sigma_mat)
    ticks <- pretty(sigma_range, 5)
    ticks <- ticks[ticks >= sigma_range[1] & ticks <= sigma_range[2]]
    tick_y <- min(Rs) + (ticks - sigma_range[1]) / diff(sigma_range) * diff(range(Rs))
    graphics::text(bar_x + bar_w + q_step * 0.15, tick_y,
         sprintf("%.1f", ticks), cex = 0.7, adj = 0)
    graphics::text(bar_x + bar_w / 2, max(Rs) + r_step * 0.5,
         "sigma", cex = 0.8)
  }
}

#' @title Sample-wise k-fold Cross-Validation for nmfae
#' @description
#' \code{nmfae.cv} performs k-fold cross-validation by splitting columns (samples)
#' of \eqn{Y_1} and \eqn{Y_2} into \code{div} folds. For each fold, the model
#' \eqn{Y_1 \approx X_1 \Theta X_2 Y_2} is fitted on the training samples and
#' predictive performance is evaluated on the held-out samples.
#'
#' When \code{Y2} is a kernel matrix created by \code{\link{nmfkc.kernel}}
#' (detected via attributes), the symmetric kernel splitting convention is used:
#' \code{Y2[train, train]} for training and \code{Y2[train, test]} for prediction.
#'
#' @param Y1 Output matrix \eqn{Y_1} (P1 x N). Non-negative.
#' @param Y2 Input matrix \eqn{Y_2} (P2 x N), or a kernel matrix (N x N).
#'   Default is \code{Y1} (autoencoder).
#' @param rank1 Integer. Rank of the response basis. Default is 2.
#' @param rank2 Integer. Rank of the covariate basis. Default (\code{NULL}) = \code{rank1}.
#' @param rank,rank.encoder Deprecated aliases of \code{rank1} / \code{rank2}.
#' @param ... Additional arguments passed to \code{\link{nmfae}}
#'   (e.g., \code{epsilon}, \code{maxit}, \code{Y1.weights}).
#'   Also accepts: \code{nfolds} (number of folds, default 5; \code{div} also accepted),
#'   \code{seed} (integer seed, default 123), \code{shuffle} (logical, default \code{TRUE}).
#'   For backward compatibility, \code{Q}, \code{R} are accepted as aliases for
#'   \code{rank}, \code{rank.encoder}.
#'
#' @return A list with components:
#' \item{objfunc}{Mean squared error per valid element over all folds.}
#' \item{sigma}{Residual standard error (RMSE), same scale as \eqn{Y_1}.}
#' \item{objfunc.block}{Per-fold squared error totals.}
#' \item{block}{Integer vector of fold assignments (1, ..., \code{div}) for each column.}
#'
#' @section Lifecycle:
#' This function is \strong{experimental}. The interface may change in
#' future versions; details are to be described in an upcoming paper.
#'
#' @seealso \code{\link{nmfae}}, \code{\link{nmfae.ecv}}, \code{\link{nmfae.kernel.beta.cv}},
#'   \code{\link{nmfkc.cv}}
#' @export
#' @examples
#' Y <- t(iris[1:30, 1:4])
#' res <- nmf.rrr.cv(Y, rank1 = 2, rank2 = 2, nfolds = 5, maxit = 500)
#' res$sigma
#'
nmf.rrr.cv <- function(Y1, Y2 = Y1, rank1 = 2, rank2 = NULL, ...,
                     rank = NULL, rank.encoder = NULL) {
  extra_cv <- list(...)
  if (is.null(rank))          rank <- rank1
  if (!is.null(extra_cv$Q))   rank <- extra_cv$Q
  if (is.null(rank.encoder))  rank.encoder <- rank2
  if (!is.null(extra_cv$R))   rank.encoder <- extra_cv$R
  if (is.null(rank.encoder))  rank.encoder <- rank
  nfolds  <- if (!is.null(extra_cv$nfolds))  extra_cv$nfolds  else if (!is.null(extra_cv$div)) extra_cv$div else 5
  seed    <- if (!is.null(extra_cv$seed))    extra_cv$seed    else 123
  shuffle <- if (!is.null(extra_cv$shuffle)) extra_cv$shuffle else TRUE
  Q <- rank; R <- rank.encoder
  div <- nfolds

  extra_args <- list(...)

  Y1 <- as.matrix(Y1); storage.mode(Y1) <- "double"
  Y2 <- as.matrix(Y2); storage.mode(Y2) <- "double"
  P1 <- nrow(Y1); N <- ncol(Y1)

  # --- Y1.weights preparation (cf. nmfkc.cv) ---
  Y1.weights <- extra_args$Y1.weights
  if (!is.null(Y1.weights) && is.vector(Y1.weights)) {
    if (length(Y1.weights) == N) {
      Y1.weights <- matrix(Y1.weights, nrow = P1, ncol = N, byrow = TRUE)
    } else if (length(Y1.weights) == 1) {
      Y1.weights <- matrix(Y1.weights, nrow = P1, ncol = N)
    } else {
      stop("Length of Y1.weights vector must match ncol(Y1) (or be 1).")
    }
  }
  if (is.null(Y1.weights)) {
    if (any(is.na(Y1))) {
      Y1.weights <- matrix(1, nrow = P1, ncol = N)
      Y1.weights[is.na(Y1)] <- 0
      Y1[is.na(Y1)] <- 0
    } else {
      Y1.weights <- matrix(1, nrow = P1, ncol = N)
    }
  } else {
    if (!is.matrix(Y1.weights)) Y1.weights <- as.matrix(Y1.weights)
    Y1.weights[is.na(Y1.weights)] <- 0
    Y1[is.na(Y1)] <- 0
  }

  # --- Detect kernel matrix (cf. nmfkc.cv) ---
  A.function <- attr(Y2, "function.name")
  is_kernel_matrix <- !is.null(A.function) && A.function == "nmfkc.kernel"
  is_symmetric <- isSymmetric(Y2, tol = .Machine$double.eps)

  # --- Create folds (sample-wise, same logic as nmfkc.cv) ---
  remainder <- N %% div
  division  <- N %/% div
  block <- integer(N)

  if (shuffle) {
    set.seed(seed)
    perm_index <- sample(1:N, N, replace = FALSE)
  } else {
    perm_index <- 1:N
  }

  processed <- 0
  for (i in 1:(div - 1)) {
    chunk <- division + ifelse(i <= remainder, 1, 0)
    idx <- perm_index[(processed + 1):(processed + chunk)]
    block[idx] <- i
    processed <- processed + chunk
  }
  block[perm_index[(processed + 1):N]] <- div

  # --- Prepare nmfae pass-through args ---
  nmfae_extra <- extra_args
  nmfae_extra$Y1.weights <- NULL
  nmfae_extra$print.trace <- NULL
  nmfae_extra$seed <- NULL

  objfunc.block <- numeric(div)
  total_valid <- 0

  for (j in 1:div) {
    train <- (block != j)
    test  <- (block == j)

    # Y1 split
    Y1_train <- Y1[, train, drop = FALSE]
    Y1_test  <- Y1[, test,  drop = FALSE]
    W_train  <- Y1.weights[, train, drop = FALSE]
    W_test   <- Y1.weights[, test,  drop = FALSE]

    # Y2 split
    if (is_symmetric && is_kernel_matrix) {
      Y2_train <- Y2[train, train, drop = FALSE]
      Y2_test  <- Y2[train, test,  drop = FALSE]
    } else {
      Y2_train <- Y2[, train, drop = FALSE]
      Y2_test  <- Y2[, test,  drop = FALSE]
    }

    # Fit on training set
    nmfae_args <- c(
      list(Y1 = Y1_train, Y2 = Y2_train, Q = Q, R = R,
           Y1.weights = W_train, print.trace = FALSE),
      nmfae_extra
    )
    res_j <- suppressMessages(do.call("nmf.rrr", nmfae_args))

    # Predict on test set
    Y1hat_test <- res_j$X1 %*% res_j$C %*% res_j$X2 %*% Y2_test

    # Evaluate weighted error (lm-style: sum(W * resid^2))
    objfunc.block[j] <- sum(W_test * (Y1_test - Y1hat_test)^2)
    total_valid <- total_valid + sum(W_test > 0)
  }

  objfunc <- sum(objfunc.block) / max(total_valid, 1)
  sigma <- sqrt(objfunc)

  result <- list(objfunc = objfunc, sigma = sigma,
                 objfunc.block = objfunc.block, block = block)
  class(result) <- "nmfae.cv"
  result
}

#' @title Plot method for nmfae.cv objects
#' @keywords internal
#' @description
#' Displays a bar chart of per-fold cross-validation errors from
#' \code{\link{nmfae.cv}}. The overall RMSE (sigma) is shown in the title.
#'
#' @param x An object of class \code{"nmfae.cv"} returned by \code{\link{nmfae.cv}}.
#' @param ... Additional graphical parameters passed to \code{barplot}.
#'
#' @return Invisible \code{NULL}. Called for its side effect (plot).
#' @seealso \code{\link{nmfae.cv}}
#' @export
plot.nmfae.cv <- function(x, ...) {
  extra_args <- list(...)
  args <- list(height = x$objfunc.block)
  if (is.null(extra_args$main))
    args$main <- sprintf("sigma = %.4f", x$sigma)
  if (is.null(extra_args$xlab)) args$xlab <- "Fold"
  if (is.null(extra_args$ylab)) args$ylab <- "Error"
  if (is.null(extra_args$col))  args$col <- "steelblue"
  if (is.null(extra_args$names.arg))
    args$names.arg <- seq_along(x$objfunc.block)
  all_args <- c(args, extra_args)
  do.call("barplot", all_args)
  invisible(NULL)
}

#' @title Optimize kernel beta for nmfae by cross-validation
#' @description
#' \code{nmfae.kernel.beta.cv} selects the optimal \code{beta} parameter of the
#' kernel function by evaluating \code{\link{nmfae.cv}} for each candidate value.
#' The kernel matrix \eqn{A = K(U, V; \beta)} replaces \eqn{Y_2} in the three-layer
#' NMF model.
#'
#' When \code{beta = NULL}, candidate values are automatically generated via
#' \code{\link{nmfkc.kernel.beta.nearest.med}}.
#'
#' @param Y1 Output matrix \eqn{Y_1} (P1 x N). Non-negative.
#' @param rank1 Integer. Rank of the response basis. Default is 2.
#' @param rank2 Integer. Rank of the covariate basis. Default (\code{NULL}) = \code{rank1}.
#' @param rank,rank.encoder Deprecated aliases of \code{rank1} / \code{rank2}.
#' @param U Covariate matrix \eqn{U} (K x M). Rows are features, columns are samples
#'   (or knot points for non-symmetric kernels).
#' @param V Covariate matrix \eqn{V} (K x N). If \code{NULL} (default), \code{V = U}
#'   and a symmetric kernel is used.
#' @param beta Numeric vector of candidate beta values. If \code{NULL}, automatically
#'   determined via \code{\link{nmfkc.kernel.beta.nearest.med}}.
#' @param plot Logical. If \code{TRUE} (default), plots the objective function curve.
#' @param ... Additional arguments. Kernel-specific args (\code{kernel}, \code{degree})
#'   are passed to \code{\link{nmfkc.kernel}}; all others
#'   (\code{div}, \code{seed}, \code{shuffle}, \code{epsilon}, \code{maxit}, etc.)
#'   are passed to \code{\link{nmfae.cv}}.
#'   For backward compatibility, \code{Q} and \code{R} are accepted as aliases for
#'   \code{rank} and \code{rank.encoder}.
#'
#' @return A list with components:
#' \item{beta}{The beta value that minimizes the cross-validation objective.}
#' \item{objfunc}{Named numeric vector of objective function values for each candidate beta.}
#'
#' @seealso \code{\link{nmfae.cv}}, \code{\link{nmfkc.kernel}},
#'   \code{\link{nmfkc.kernel.beta.cv}}
#' @export
#' @examples
#' Y <- matrix(cars$dist, nrow = 1)
#' U <- matrix(cars$speed, nrow = 1)
#' res <- nmf.rrr.kernel.beta.cv(Y, rank1 = 1, rank2 = 1, U = U,
#'                              beta = c(0.01, 0.02, 0.05), nfolds = 5)
#' res$beta
#'
nmf.rrr.kernel.beta.cv <- function(Y1, rank1 = 2, rank2 = NULL, U, V = NULL,
                                  beta = NULL, plot = TRUE, ...,
                                  rank = NULL, rank.encoder = NULL) {

  extra_args <- list(...)
  # rank1/rank2 = response/covariate basis ranks; legacy rank/rank.encoder/Q/R
  if (is.null(rank))          rank <- rank1
  if (!is.null(extra_args$Q)) rank <- extra_args$Q
  if (is.null(rank.encoder))  rank.encoder <- rank2
  if (!is.null(extra_args$R)) rank.encoder <- extra_args$R
  if (is.null(rank.encoder))  rank.encoder <- rank
  extra_args <- extra_args[!names(extra_args) %in% c("Q", "R")]

  # Separate kernel-specific args from cv/nmfae args
  kernel_only <- c("kernel", "degree")
  kernel_args <- extra_args[names(extra_args) %in% kernel_only]
  cv_args     <- extra_args[!names(extra_args) %in% kernel_only]

  # Auto-generate beta candidates if not provided
  if (is.null(beta)) {
    if (is.null(V)) V <- U
    result.beta <- nmfkc.kernel.beta.nearest.med(V)
    beta <- result.beta$beta_candidates
    if (is.null(beta) || length(beta) == 0)
      stop("Failed to determine beta candidates from nearest-neighbor median.")
  }

  objfuncs <- numeric(length(beta))
  for (i in seq_along(beta)) {
    start.time <- Sys.time()
    message(paste0("beta=", beta[i], "..."), appendLF = FALSE)

    kernel_call <- c(list(U = U, V = V, beta = beta[i]), kernel_args)
    A <- do.call("nmfkc.kernel", kernel_call)

    cv_call <- c(list(Y1 = Y1, Y2 = A, rank = rank, rank.encoder = rank.encoder), cv_args)
    result <- do.call("nmf.rrr.cv", cv_call)

    objfuncs[i] <- result$objfunc

    end.time <- Sys.time()
    diff.time <- difftime(end.time, start.time, units = "sec")
    diff.time.st <- ifelse(diff.time <= 180,
                           paste0(round(diff.time, 1), "sec"),
                           paste0(round(diff.time / 60, 1), "min"))
    message(diff.time.st)
  }

  i0 <- which.min(objfuncs)
  beta.best <- beta[i0]

  names(objfuncs) <- beta
  result <- list(beta = beta.best, objfunc = objfuncs)
  class(result) <- "nmfae.kernel.beta.cv"
  if (plot) plot(result)
  result
}

#' @title Plot method for nmfae.kernel.beta.cv objects
#' @keywords internal
#' @description
#' Displays the cross-validation objective function across candidate
#' \code{beta} values (log scale). The optimal beta is highlighted in red.
#'
#' @param x An object of class \code{"nmfae.kernel.beta.cv"} returned by
#'   \code{\link{nmfae.kernel.beta.cv}}.
#' @param ... Additional graphical parameters passed to \code{plot}.
#'
#' @return Invisible \code{NULL}. Called for its side effect (plot).
#' @seealso \code{\link{nmfae.kernel.beta.cv}}
#' @export
plot.nmfae.kernel.beta.cv <- function(x, ...) {
  beta <- as.numeric(names(x$objfunc))
  objfuncs <- x$objfunc
  i0 <- which.min(objfuncs)

  extra_args <- list(...)
  args <- list(x = beta, y = objfuncs, type = "l", col = 2, log = "x")
  if (is.null(extra_args$xlab)) args$xlab <- "beta"
  if (is.null(extra_args$ylab)) args$ylab <- "objfunc"
  if (is.null(extra_args$main))
    args$main <- sprintf("Best beta = %g", x$beta)
  all_args <- c(args, extra_args)
  do.call("plot", all_args)
  graphics::points(beta[i0], objfuncs[i0], cex = 3, col = 2)
  graphics::text(beta, objfuncs,
                 format(beta, scientific = TRUE, digits = 5))
  invisible(NULL)
}

#' @title DOT graph visualization for nmfae objects
#' @description
#' \code{nmfae.DOT} generates a DOT language string for visualizing the structure
#' of a three-layer NMF model. Two graph types are supported:
#' \code{"XCX"} shows encoder factors, \eqn{\Theta}, and decoder factors;
#' \code{"YXCXY"} shows the full structure from \eqn{Y_2} through \eqn{X_2}, \eqn{\Theta},
#' \eqn{X_1} to \eqn{Y_1}.
#'
#' Edge widths are proportional to matrix element values, and edges below
#' \code{threshold} are omitted for clarity.
#'
#' @param result An object of class \code{"nmfae"} returned by \code{\link{nmfae}}.
#' @param type Character. Graph type: \code{"XCX"} (default) or \code{"YXCXY"}.
#' @param threshold Numeric. Edges with values below this are omitted. Default is 0.01.
#' @param sig.level Numeric or \code{NULL}. Significance level for filtering C edges
#'   when inference results are available. Only edges with p-value below \code{sig.level}
#'   are shown, with significance stars. Set to \code{NULL} to disable. Default is 0.1.
#' @param rankdir Character. Graph direction for DOT layout. Default is \code{"LR"} (left to right).
#' @param fill Logical. If \code{TRUE}, nodes are filled with color. Default is \code{TRUE}.
#' @param weight_scale Numeric. Base scale factor for edge widths. Default is 5.
#' @param weight_scale_x1 Numeric. Scale factor for \eqn{X_1} edges.
#' @param weight_scale_theta Numeric. Scale factor for \eqn{\Theta} edges.
#' @param weight_scale_x2 Numeric. Scale factor for \eqn{X_2} edges.
#' @param Y1.label Character vector of output variable labels.
#' @param X1.label Character vector of decoder basis labels.
#' @param X2.label Character vector of encoder basis labels.
#' @param Y2.label Character vector of input variable labels.
#' @param Y1.title Character. Title for output node group. Default is \code{"Output (Y1)"}.
#' @param X1.title Character. Title for the response-basis node group. Default is \code{"Response (X1)"}.
#' @param X2.title Character. Title for the covariate-basis node group. Default is \code{"Covariate (X2)"}.
#' @param Y2.title Character. Title for input node group. Default is \code{"Input (Y2)"}.
#' @param hide.isolated Logical. If \code{TRUE} (default), Y1 and Y2 nodes that have no
#'   edges at or above \code{threshold} are excluded from the graph. Only
#'   applies when \code{type = "YXCXY"}.
#'
#' @return A character string containing the DOT graph specification.
#'
#' @section Lifecycle:
#' This function is \strong{experimental}. The interface may change in
#' future versions; details are to be described in an upcoming paper.
#'
#' @seealso \code{\link{nmfae}}
#' @examples
#' \donttest{
#' set.seed(1)
#' Y <- matrix(runif(20), nrow = 4)
#' res <- nmf.rrr(Y, rank1 = 2)
#' dot <- nmf.rrr.DOT(res)
#' }
#' @export
nmf.rrr.DOT <- function(result,
                      type = c("XCX", "YXCXY"),
                      threshold = 0.01,
                      sig.level = 0.1,
                      rankdir = "LR",
                      fill = TRUE,
                      weight_scale = 5,
                      weight_scale_x1 = weight_scale,
                      weight_scale_theta = weight_scale,
                      weight_scale_x2 = weight_scale,
                      Y1.label = NULL, X1.label = NULL,
                      X2.label = NULL, Y2.label = NULL,
                      Y1.title = "Output (Y1)",
                      X1.title = "Response (X1)",
                      X2.title = "Covariate (X2)",
                      Y2.title = "Input (Y2)",
                      hide.isolated = TRUE) {

  type <- match.arg(type)

  X1 <- result$X1        # P1 x Q
  C_mat <- result$C   # Q x R
  X2 <- result$X2        # R x P2

  P1 <- nrow(X1); Q <- ncol(X1)
  R <- nrow(X2); P2 <- ncol(X2)

  # --- Labels ---
  Y1_labels <- if (!is.null(Y1.label)) Y1.label else rownames(X1)
  if (is.null(Y1_labels)) Y1_labels <- paste0("Y1_", seq_len(P1))
  X1_labels <- if (!is.null(X1.label)) X1.label else colnames(X1)
  if (is.null(X1_labels)) X1_labels <- paste0("Resp", seq_len(Q))
  X2_labels <- if (!is.null(X2.label)) X2.label else rownames(X2)
  if (is.null(X2_labels)) X2_labels <- paste0("Cov", seq_len(R))
  Y2_labels <- if (!is.null(Y2.label)) Y2.label else colnames(X2)
  if (is.null(Y2_labels)) Y2_labels <- paste0("Y2_", seq_len(P2))

  # --- Node IDs ---
  sanitize <- function(s) gsub("[^[:alnum:]_.]", "_", s, perl = TRUE)
  Y1_ids <- sanitize(paste0("Y1_", seq_len(P1)))
  X1_ids <- sanitize(paste0("X1_", seq_len(Q)))
  X2_ids <- sanitize(paste0("X2_", seq_len(R)))
  Y2_ids <- sanitize(paste0("Y2_", seq_len(P2)))

  # --- Filter isolated Y1/Y2 nodes (hide.isolated) ---
  idx_Y1 <- seq_len(P1)
  idx_Y2 <- seq_len(P2)
  if (isTRUE(hide.isolated) && type == "YXCXY") {
    # Y1 is connected if any X1[i, ] >= threshold
    used_Y1 <- apply(X1, 1L, function(row) any(row >= threshold, na.rm = TRUE))
    idx_Y1 <- which(used_Y1)
    # Y2 is connected if any X2[, j] >= threshold
    used_Y2 <- apply(X2, 2L, function(col) any(col >= threshold, na.rm = TRUE))
    idx_Y2 <- which(used_Y2)
  }

  # --- Helpers ---
  pw <- function(value, max_value, ws) {
    if (!is.finite(max_value) || max_value <= 0) return(0.5)
    if (!is.finite(value) || value <= 0) return(0.5)
    max(0.5, value * ws / max_value)
  }
  dg <- max(0L, nchar(sub("^0\\.", "", format(threshold, scientific = FALSE, trim = TRUE))))
  fmtc <- function(v) sprintf(paste0("%.", dg, "f"), v)

  # --- Cluster builder ---
  make_cluster <- function(id, title, ids, labels, shape, color) {
    style_str <- if (fill) {
      sprintf('    node [shape=%s, style="filled,rounded", fillcolor="%s", color=black, penwidth=1.5];\n', shape, color)
    } else {
      sprintf('    node [shape=%s, style="rounded", color=black, penwidth=1.5];\n', shape)
    }
    s <- paste0('  subgraph cluster_', id, ' {label="', title,
                '" style="rounded" color="black" penwidth=1.0;\n', style_str)
    for (i in seq_along(ids)) {
      s <- paste0(s, sprintf('    %s [label="%s"];\n', ids[i], labels[i]))
    }
    paste0(s, "  }\n")
  }

  # --- Edge builder ---
  make_edges <- function(from_ids, to_ids, mat, ws, comment,
                         dir = "row_to_col", stars_mat = NULL,
                         show_mat = NULL) {
    # dir = "row_to_col": mat[i,j] -> from_ids[j] -> to_ids[i]
    # dir = "col_to_row": mat[i,j] -> from_ids[i] -> to_ids[j]
    # stars_mat: optional matrix of "" or "*"/"**"/"***"
    # show_mat: optional logical matrix; if provided, overrides threshold filter
    s <- paste0('\n  // ', comment, '\n',
                '  edge [color="gray0", fontcolor="gray0", style=solid];\n')
    max_val <- suppressWarnings(max(mat, na.rm = TRUE))
    if (!is.finite(max_val) || max_val <= 0) return(s)
    nr <- nrow(mat); nc <- ncol(mat)
    for (i in seq_len(nr)) {
      for (j in seq_len(nc)) {
        val <- mat[i, j]
        show <- if (!is.null(show_mat)) show_mat[i, j]
                else is.finite(val) && val >= threshold
        if (show) {
          pen <- pw(val, max_val, ws)
          lab <- fmtc(val)
          if (!is.null(stars_mat)) lab <- paste0(lab, stars_mat[i, j])
          if (dir == "row_to_col") {
            s <- paste0(s, sprintf('  %s -> %s [label="%s", penwidth=%.2f];\n',
                                   from_ids[j], to_ids[i], lab, pen))
          } else {
            s <- paste0(s, sprintf('  %s -> %s [label="%s", penwidth=%.2f];\n',
                                   from_ids[i], to_ids[j], lab, pen))
          }
        }
      }
    }
    s
  }

  # --- Significance stars and p-value filter for Theta edges ---
  C_stars <- NULL
  C_show  <- NULL  # logical matrix for sig.level filter
  if (!is.null(result$coefficients)) {
    C_stars <- matrix("", nrow = Q, ncol = R)
    C_pval  <- matrix(NA_real_, nrow = Q, ncol = R)
    cf <- result$coefficients
    dec_names <- rownames(C_mat)  # current basis names (may be renamed)
    enc_names <- colnames(C_mat)  # current covariate names (may be renamed)
    for (k in seq_len(nrow(cf))) {
      q <- match(cf$Basis[k], dec_names)
      r <- match(cf$Covariate[k], enc_names)
      if (!is.na(q) && !is.na(r) && !is.na(cf$p_value[k])) {
        p <- cf$p_value[k]
        C_pval[q, r] <- p
        if (p < 0.001)      C_stars[q, r] <- "***"
        else if (p < 0.01)  C_stars[q, r] <- "**"
        else if (p < 0.05)  C_stars[q, r] <- "*"
      }
    }
    if (!is.null(sig.level)) {
      C_show <- !is.na(C_pval) & C_pval < sig.level
    }
  }

  # === Build DOT ===
  scr <- paste0(
    "digraph NMF_AE {\n",
    "  graph [rankdir=", rankdir, " compound=true];\n",
    "  splines=true; nodesep=0.4; ranksep=0.7; fontname=\"Arial\";\n",
    '  edge [fontname="Arial", fontsize=8, arrowhead=open];\n'
  )

  if (type == "YXCXY") {
    # Clusters: Y2 -> X2 -> X1 -> Y1
    scr <- paste0(scr,
      make_cluster("Y2", Y2.title, Y2_ids[idx_Y2], Y2_labels[idx_Y2], "box", "lightcoral"),
      make_cluster("X2", X2.title, X2_ids, X2_labels, "ellipse", "wheat"),
      make_cluster("X1", X1.title, X1_ids, X1_labels, "ellipse", "wheat"),
      make_cluster("Y1", Y1.title, Y1_ids[idx_Y1], Y1_labels[idx_Y1], "box", "lightblue")
    )
    # Edges: Y2 -> X2 (X2 matrix: R x P2, X2[r,j] = weight from Y2_j to X2_r)
    scr <- paste0(scr,
      make_edges(Y2_ids[idx_Y2], X2_ids, X2[, idx_Y2, drop = FALSE], weight_scale_x2, "Y2 -> X2 (encoder)", "row_to_col"))
    # Edges: X2 -> X1 (C: Q x R, C[q,r] = weight from X2_r to X1_q)
    scr <- paste0(scr,
      make_edges(X2_ids, X1_ids, C_mat, weight_scale_theta, "X2 -> X1 (C)",
                 "row_to_col", stars_mat = C_stars, show_mat = C_show))
    # Edges: X1 -> Y1 (X1: P1 x Q, X1[i,q] = weight from X1_q to Y1_i)
    scr <- paste0(scr,
      make_edges(X1_ids, Y1_ids[idx_Y1], X1[idx_Y1, , drop = FALSE], weight_scale_x1, "X1 -> Y1 (decoder)", "row_to_col"))

  } else {
    # XCX: X2 -> X1 only
    scr <- paste0(scr,
      make_cluster("X2", X2.title, X2_ids, X2_labels, "ellipse", "wheat"),
      make_cluster("X1", X1.title, X1_ids, X1_labels, "ellipse", "wheat")
    )
    scr <- paste0(scr,
      make_edges(X2_ids, X1_ids, C_mat, weight_scale_theta, "X2 -> X1 (C)",
                 "row_to_col", stars_mat = C_stars, show_mat = C_show))
  }

  result <- paste0(scr, "}\n")
  class(result) <- c("nmfae.DOT", "nmfkc.DOT")
  result
}

Try the nmfkc package in your browser

Any scripts or data that you put into this service are public.

nmfkc documentation built on July 14, 2026, 1:07 a.m.