as_factoextra_pca().get_facto_class() your classget_eig() your eigenvaluesget_*() extractor(s)factoextra visualizes the output of many different multivariate-analysis packages (FactoMineR, stats, ade4, ca, MASS, ExPosition) through one unified, ggplot2-based interface. The same fviz_pca_*(), fviz_ca_*(), fviz_mca_*() functions work whether your PCA came from stats::prcomp(), FactoMineR::PCA(), ade4::dudi.pca() or ExPosition::epPCA().
This is possible because of a strict two-layer design. Adding a new backend (say, an analysis object from another package) almost never requires touching the plotting code: you only teach a handful of extractor functions how to read the new object. This vignette documents that contract precisely so you can add a backend yourself and submit it as a pull request.
as_factoextra_pca()Before writing any backend code, check whether the constructor is all you need. If you already have coordinates from any method, wrap them with as_factoextra_pca() and the whole fviz_pca_*() family works immediately — no source edits, no S3 methods.
The constructor takes individual coordinates (ind.coord) and, optionally, variable coordinates/loadings (var.coord) and eigenvalues (eig). Squared cosines and contributions are derived from the coordinates when you don’t supply them.
Here we illustrate it with a base-R PCA (stats::prcomp()). Passing its scores, variable-component correlations, and eigenvalues through the constructor reproduces the standard factoextra PCA representation:
library(factoextra)
#> Le chargement a nécessité le package : ggplot2
#> Warning: le package 'ggplot2' a été compilé avec la version R 4.5.2
#> Welcome to factoextra!
#> Want to learn more? See two factoextra-related books at https://www.datanovia.com/library/principal-component-methods
# A base-R PCA (any analysis that yields coordinates would do)
pca <- prcomp(iris[, -5], scale. = TRUE)
# Wrap the coordinates into a factoextra-ready object
res <- as_factoextra_pca(
ind.coord = pca$x,
var.coord = sweep(pca$rotation, 2, pca$sdev, "*"),
eig = pca$sdev^2,
scale.unit = TRUE
)
# Now the full fviz_pca_* family works on `res`
fviz_pca_biplot(res, label = "var", habillage = iris$Species,
addEllipses = TRUE)The same pattern turns the output of any eigenvalue-based method factoextra doesn’t natively know about into elegant biplots — e.g. classical MDS / PCoA (coordinates only; supply var.coord only if your method has variable loadings):
mds <- stats::cmdscale(dist(scale(mtcars)), k = 3) # classical MDS (eigen-based)
fviz_pca_ind(as_factoextra_pca(ind.coord = mds), repel = TRUE)When you don’t pass eig, it defaults to the variance of each coordinate column (the natural eigenvalue), so the scree plot and axis percentages still work. When you don’t pass cos2/contrib, they are computed from the coordinates (cos2 is then the quality of representation within the supplied dimensions).
A UMAP or t-SNE embedding has no eigenvalues, so it must not go through as_factoextra_pca() — a scree plot, variable loadings or a correlation circle would be meaningless for it. Plot it with fviz_umap() / fviz_tsne() instead, which label the axes UMAP1/UMAP2 (tSNE1/tSNE2) with no percentage, omit the eigen surface entirely, and default the group outline to a convex hull rather than a confidence ellipse (which would assume a metric the embedding does not preserve):
set.seed(123)
um <- uwot::umap(iris[, 1:4])
fviz_umap(um, habillage = iris$Species, addEllipses = TRUE)
fviz_umap(um, col.ind = iris$Petal.Length) # colour by a continuous feature valueIn an embedding, tight local neighbourhoods are trustworthy, but the size of a cluster, the distance between well-separated clusters and the empty space between them are not quantitatively meaningful and change with the method’s perplexity / n_neighbors settings (Wattenberg, Viégas & Johnson, 2016) — read groupings, not geometry.
Use the constructor when you have results in hand. Read on if instead you want factoextra to recognise a class of analysis objects directly (so users of your package can call fviz_pca_biplot(my_object) without the constructor).
For every analysis family, factoextra has two layers:
get_<method>() — extract. Reads coordinates, squared cosines (cos2) and contributions from a backend object and returns them as a standardized list. This is where backend-specific knowledge lives.fviz_<method>() — visualize. Builds the ggplot from that standardized list. It is backend-agnostic.The glue is facto_summarize(), which calls .get_facto_class() to identify the analysis type, dispatches to the right get_*() extractor, and hands a tidy data frame to fviz().
Key insight. Once
.get_facto_class(),get_eig()and the relevantget_*()extractor recognise your class, everyfviz_*(),fviz_contrib(),fviz_cos2()and screeplot function works automatically — because none of them hard-code backend class names; they only read$coord,$cos2and$contrib.
So extending factoextra means editing three or four functions, not fifty.
Every extractor returns a list whose elements are matrices with:
Dim.1, Dim.2, … (one per retained axis), and| Extractor | Required list elements |
|---|---|
get_pca_ind() |
$coord, $cos2, $contrib |
get_pca_var() |
$coord, $cor, $cos2, $contrib |
get_ca_row() / get_ca_col() |
$coord, $cos2, $contrib, $inertia |
get_mca_ind() / get_mca_var() |
$coord, $cos2, $contrib |
The list is given S3 class c("factoextra", ...). get_eig() returns a data frame (see below). Contributions are expressed as percentages (note the * 100 in the examples).
.get_facto_class() your class.get_facto_class() (in R/utilities.R) maps a backend object to one canonical analysis token: "PCA", "CA", "MCA", "FAMD", "MFA" or "HMFA". Add one else if branch before the final stop():
For a wrapper object (a list that carries the real result in a slot), test the inner class — this is exactly how ExPosition is handled:
get_eig() your eigenvaluesget_eig() (in R/eigenvalue.R) returns a data frame with exactly these three columns and Dim.N row names:
eigenvalue variance.percent cumulative.variance.percent
Dim.1 2.918 72.96 72.96
Dim.2 0.914 22.85 95.81
...
You only need to supply the raw eigenvalues; the helper normalizes them to percentages and cumulative percentages. Add a branch:
# inside get_eig(), before the final stop()
else if (inherits(X, "myPCA"))
eig <- X$eigenvalues # a numeric vector of eigenvaluesIf your object already stores a FactoMineR-style $eig data frame, it is used as-is. The ExPosition branch is a one-liner:
get_*() extractor(s)Add one else if branch per extractor for your method. Below are the real templates you can copy.
get_pca_ind() and get_pca_var() live in R/get_pca.R. The two existing templates are prcomp (stats) and dudi (ade4):
# get_pca_ind(): stats::prcomp template
else if (inherits(res.pca, "prcomp")) {
ind.coord <- res.pca$x
data <- .prcomp_reconst(res.pca)
ind <- .get_pca_ind_results(ind.coord, data, res.pca$sdev^2,
res.pca$center, res.pca$scale)
}
# get_pca_ind(): ade4 dudi template
else if (inherits(res.pca, "pca") && inherits(res.pca, "dudi")) {
ind.coord <- res.pca$li
data <- sweep(res.pca$tab, 2, res.pca$norm, "*")
data <- sweep(data, 2, res.pca$cent, "+")
ind <- .get_pca_ind_results(ind.coord, data, res.pca$eig,
res.pca$cent, res.pca$norm)
}# get_pca_var(): stats::prcomp template
else if (inherits(res.pca, "prcomp")) {
var.cor <- sweep(res.pca$rotation, 2, res.pca$sdev, "*")
var <- .get_pca_var_results(var.cor)
}
# get_pca_var(): ade4 dudi template
else if (inherits(res.pca, "pca") && inherits(res.pca, "dudi")) {
var <- .get_pca_var_results(res.pca$co)
}The helpers .get_pca_ind_results() and .get_pca_var_results() compute cos2 and contrib for you, so if you can produce coordinates you are nearly done.
For CA backends edit get_ca_row() and get_ca_col() in R/get_ca.R, returning $coord, $cos2, $contrib and $inertia with Dim.N columns:
# get_ca_row(): build the standardized list directly
else if (inherits(res.ca, "myCA")) {
coord <- res.ca$row.coord
cos2 <- res.ca$row.cos2
contrib <- res.ca$row.contrib * 100
inertia <- res.ca$row.inertia
colnames(coord) <- colnames(cos2) <- colnames(contrib) <-
paste0("Dim.", seq_len(ncol(coord)))
row <- list(coord = coord, cos2 = cos2, contrib = contrib, inertia = inertia)
}Don’t forget .get_ca_mass() (in R/utilities.R). CA biplot scaling (symmetric / row-/col-principal maps) needs row and column masses. Add a branch returning a named numeric vector of masses:
Edit get_mca_ind() and get_mca_var() in R/get_mca.R, returning $coord, $cos2, $contrib (same Dim.N convention as above).
ExPosition is the most recent multi-family backend (PCA + CA + MCA) and is the best template to study. Every place it is wired in:
| Extension point | Source owner |
|---|---|
| Class dispatch (wrapper) | .get_facto_class() in R/utilities.R |
| Eigenvalues | get_eig() in R/eigenvalue.R |
get_pca_ind() |
R/get_pca.R |
get_pca_var() |
R/get_pca.R |
get_ca_col() |
R/get_ca.R |
get_ca_row() |
R/get_ca.R |
.get_ca_mass() |
R/utilities.R |
get_mca_var() |
R/get_mca.R |
get_mca_ind() |
R/get_mca.R |
For instance the PCA-individual branch simply maps ExPosition’s slots onto the standardized names:
# R/get_pca.R — ExPosition epPCA, individuals
else if (inherits(res.pca, "expoOutput") &&
inherits(res.pca$ExPosition.Data, "epPCA")) {
res <- res.pca$ExPosition.Data
ind <- list(coord = res$fi, cos2 = res$ri, contrib = res$ci * 100)
}Once these branches exist, the full ExPosition workflow plots like any other:
Ecology users often ask for vegan::rda() / vegan::cca() support. These are not bundled (they are method-specific), but they are a good illustration of how you would add a backend yourself. A constrained ordination is CA-like, so you would map vegan’s scores onto the CA extractors. This is a sketch, not a supported path — treat it as a starting point for a contribution:
# .get_facto_class(): treat an unconstrained CA component as CA
else if (inherits(X, c("cca", "rda")))
facto_class <- "CA"
# get_eig(): vegan stores eigenvalues per axis
else if (inherits(X, c("cca", "rda")))
eig <- vegan::eigenvals(X)
# get_ca_row()/get_ca_col(): use vegan::scores() for sites / species
else if (inherits(res.ca, c("cca", "rda"))) {
sc <- vegan::scores(res.ca, display = "sites", scaling = "sites")
coord <- as.matrix(sc)
colnames(coord) <- paste0("Dim.", seq_len(ncol(coord)))
# cos2 / contrib / inertia derived from the ordination as appropriate
row <- list(coord = coord, cos2 = cos2, contrib = contrib, inertia = inertia)
}The exact mapping (scaling choice, how to derive cos2/contrib, constrained vs. unconstrained axes) is the real work and is method-specific — which is why it belongs in a focused contribution rather than the core package.
Mirror the existing smoke tests in tests/testthat/test-ca-backends-smoke.R. They assert the standardized contract holds, which is exactly what a new backend must satisfy:
test_that("CA extractors work with <your package> outputs", {
skip_if_not_installed("yourpkg")
data("housetasks", package = "factoextra")
res <- yourpkg::your_ca(housetasks)
cols <- get_ca_col(res)
rows <- get_ca_row(res)
expect_s3_class(cols, "factoextra")
expect_true(all(c("coord", "cos2", "contrib") %in% names(cols)))
expect_equal(nrow(cols$coord), ncol(housetasks))
expect_equal(nrow(rows$coord), nrow(housetasks))
})Use skip_if_not_installed() so the test is skipped when the optional backend is absent (backends belong in Suggests, never Imports).
else if branches: .get_facto_class(), get_eig(), the relevant get_*() extractor(s), and .get_ca_mass() for CA.test-ca-backends-smoke.R, guarded with skip_if_not_installed().Suggests (not Imports).NEWS.md and open a pull request at https://github.com/kassambara/factoextra/pulls.That’s it — no fviz_*() changes required.