Executive Summary
How 'Brand Chosen' and 'Attribute Named' map together
The short answer
Brands and attributes move together in customers' minds in a clear, strong pattern. The chi-square test rejects independence at p < 0.001 with a Cramér's V of 0.504, indicating a strong association. The first two dimensions of the map capture 92.2% of this association, with dimension 1 alone accounting for 66.7% of the total inertia of 1.0175.
The detail
Across 1,500 observations in a 5 × 5 table, the chi-square statistic is 1526.25 on 16 degrees of freedom (p < 0.001). The strongest single over-representation is LuxeBrand paired with Prestigious: observed 170 times against 45 expected under independence. Dimension 1 separates LuxeBrand and Prestigious (negative end) from BudgetCo and Cheapest price (positive end). Every plotted category has at least 80.3% of its variation captured by the two dimensions shown. Note: the distance between a 'Brand Chosen' point and an 'Attribute Named' point on this symmetric map is not a measure of association; read direction from the origin and within-set distances instead.
What this can't tell you
The map shows which brands and attributes co-occur more or less than chance. It does not reveal causation or which attribute drives brand choice.
Analysis Overview
Correspondence analysis of 'Brand Chosen' by 'Attribute Named' across 1,500 observations.
The short answer
Correspondence analysis reveals how brands and attributes cluster in customers' minds by mapping both onto the same axes. Rather than just testing whether they're related, this method shows the structure of that relationship across 1,500 observations in a 5 × 5 table.
The detail
A chi-square test answers whether two categorical variables are independent; correspondence analysis answers how by decomposing the chi-square statistic (divided by sample size) into independent dimensions. The total inertia here is 1.0175, split across 4 dimensions. The test rejects independence (p < 0.001), confirming there is structure to read. Every category receives a coordinate on the map, so the pattern becomes visible.
What this can't tell you
Correspondence analysis describes association only, not causation. A third factor related to both variables would produce the same map. The method is observational and cannot establish causal direction or mechanism.
Data Quality
Blank recoding, rare-category pooling, and the resulting table size.
The short answer
All 1,500 observations entered the analysis intact. The rare-category threshold of 15 observations was applied to prevent noise-driven distortion, but no categories fell below it, so no pooling occurred.
The detail
Both the 'Brand Chosen' and 'Attribute Named' columns were complete with no blanks to recode. The rare-category threshold is set at 15 observations—the larger of 5 and one percent of 1,500—to prevent categories carried by only a handful of cases from landing far from the origin on sampling noise alone. At most 8 categories per column are retained after pooling. Here, every category carried at least 15 observations, so the 5 × 5 table retained all original levels.
What this can't tell you
This step does not evaluate whether the sample composition itself is representative of the customer population. It only ensures that the contingency table is stable enough for the geometric decomposition to run without distortion from sparse cells.
Correspondence Biplot
'Brand Chosen' and 'Attribute Named' categories on one map.
The short answer
The map shows a tight, one-dimensional pattern: luxury and budget brands cluster at opposite ends of dimension 1, with their associated attributes aligned in the same directions. BudgetCo is the most distinctive profile, sitting furthest from the origin. All 10 categories are readable on this plane.
The detail
Dimension 1 (66.7% of inertia) and dimension 2 (25.6%) together display 92.2% of the association. BudgetCo has a mass of 15.67% and quality of 95.6%, making it the most distinctive category. LuxeBrand (mass 17.73%, quality 97.4%) sits at dim_1 = −1.1648; BudgetCo sits at dim_1 = 1.2035. On the attribute side, Prestigious (dim_1 = −1.1566, quality 96.8%) aligns with LuxeBrand, while Cheapest price (dim_1 = 1.1984, quality 95.6%) aligns with BudgetCo. The lowest quality score is Good value at 80.3%, still above the 40% readability threshold. The two 'Brand Chosen' points closest together—MidRange and ValueMart—indicate similar attribute profiles across their customer bases.
What this can't tell you
The map does not show which attributes drive brand preference; it shows which co-occur. Distance between a brand point and an attribute point is not interpretable because each set is scaled to its own inertia.
Dimensions and Inertia Explained
How the total inertia of 1.0175 splits across 4 dimension(s).
The short answer
Two dimensions capture 92.2% of the association structure. Dimension 1 alone accounts for 66.7%, leaving only 7.8% scattered across two undrawn dimensions—a faithful summary of the table.
The detail
The table's total inertia of 1.0175 splits as follows: dimension 1 carries 66.7% (eigenvalue 0.6784), dimension 2 carries 25.6% (eigenvalue 0.26), dimension 3 carries 6.87% (eigenvalue 0.0699), and dimension 4 carries 0.9% (eigenvalue 0.0092). The cumulative inertia through dimension 2 is 92.23%. Inertia is chi-square divided by sample size, so these percentages partition the same quantity the test of independence measures.
What this can't tell you
The undrawn 7.8% on dimensions 3 and 4 remains invisible on the map. If a subtle brand–attribute pairing lives primarily on those dimensions, it will not appear here. However, the 92.2% captured is substantial enough for strategic decisions.
Is There Anything to Map?
The test of independence and the total inertia the map divides up.
| Measure | Value | Interpretation |
|---|---|---|
| Total inertia | 1.0175 | Chi-square divided by the 1,500 observations — the total amount of association the map splits up. |
| Chi-square statistic | 1526.25 | How far the observed table sits from the counts independence would predict. |
| Degrees of freedom | 16 | (5 - 1) × (5 - 1). |
| P-value | < 0.001 | Independence is rejected at the 0.05 level, so there is a real pattern to map. |
| Cramér's V | 0.504 | Association strength is strong (0 = none, 1 = perfect; below 0.1 negligible, 0.1 to 0.3 weak, 0.3 to 0.5 moderate, above 0.5 strong). |
| Dimensions available | 4 | min(rows, columns) - 1 = 4 independent dimensions carry all of the inertia. |
| Inertia on dimensions 1 and 2 | 92.2% | The share of the association visible on the drawn map; the rest lives on 2 dimension(s) that are not drawn. |
The short answer
Brands and attributes are strongly and significantly associated. Cramér's V is 0.504 (strong), the chi-square test yields p < 0.001, and the total inertia of 1.0175 represents real structure in the data, not random noise.
The detail
Chi-square = 1526.25 on 16 degrees of freedom, p < 0.001. Total inertia is 1.0175, the amount of association the map divides across four available dimensions. Cramér's V of 0.504 falls in the strong band (above 0.5). Dimensions 1 and 2 capture 92.2% of this inertia, leaving 4 independent dimensions available in the full table. The agreement between statistical significance and effect size means the pattern is both real and large enough to act on.
What this can't tell you
Significance and strength are separate measures; large samples can make weak associations significant. Here both are strong, so no correction is needed.
Which Points Can Be Trusted
Per-category contribution to each dimension and quality of representation.
| Variable | Category | Mass PCT | Dim 1 | Dim 2 | Contribution Dim1 PCT | Contribution Dim2 PCT | Quality PCT | Reliability |
|---|---|---|---|---|---|---|---|---|
| Brand Chosen | LuxeBrand | 17.73 | -1.165 | 0.6854 | 35.5 | 32 | 97.4 | well represented |
| Attribute Named | Prestigious | 16.93 | -1.157 | 0.6865 | 33.4 | 30.7 | 96.8 | well represented |
| Attribute Named | Reliable | 24.33 | 0.0267 | -0.6175 | 0 | 35.7 | 96.2 | well represented |
| Brand Chosen | BudgetCo | 15.67 | 1.204 | 0.6935 | 33.5 | 29 | 95.6 | well represented |
| Attribute Named | Cheapest price | 15.87 | 1.198 | 0.6837 | 33.6 | 28.5 | 95.6 | well represented |
| Brand Chosen | MidRange | 22.53 | -0.0245 | -0.5943 | 0 | 30.6 | 94.8 | well represented |
| Attribute Named | Stylish design | 22.4 | -0.6925 | -0.1994 | 15.8 | 3.4 | 84.1 | well represented |
| Brand Chosen | PremiumOne | 21.6 | -0.6497 | -0.261 | 13.4 | 5.7 | 82 | well represented |
| Brand Chosen | ValueMart | 22.47 | 0.7294 | -0.1776 | 17.6 | 2.7 | 81.4 | well represented |
| Attribute Named | Good value | 20.47 | 0.7541 | -0.1456 | 17.2 | 1.7 | 80.3 | well represented |
The short answer
All 10 plotted categories are well-represented on the map. LuxeBrand and Prestigious are the best-captured at 97.4% and 96.8% respectively; even the least-captured, Good value, reaches 80.3%—well above the 40% threshold for interpretability.
The detail
Quality (cos-squared) measures how much of a category's departure from the average profile is captured by dimensions 1 and 2. LuxeBrand contributes 35.5% of dimension 1's 'Brand Chosen' side and 32% of dimension 2; Prestigious contributes 33.4% and 30.7% on the 'Attribute Named' side. Within each variable, contributions sum to 100%. BudgetCo and Cheapest price tie at 95.6% quality; Reliable reaches 96.2%. The weakest is Good value at 80.3%, still well above the 40% floor. No category falls into the "not interpretable" zone.
What this can't tell you
A category with low quality can still sit far from the origin on the map—distance alone is not sufficient evidence. However, all categories here exceed 80.3%, so every plotted position is a fair summary of that category's actual profile in the table.
Reading the Biplot — and the One Rule Everyone Breaks
What each distance and direction on the 'Brand Chosen' by 'Attribute Named' map does and does not mean.
| Rule | Detail |
|---|---|
| Distance between two 'Brand Chosen' points | Interpretable. Two 'Brand Chosen' categories that sit close together have similar profiles across 'Attribute Named'. |
| Distance between two 'Attribute Named' points | Interpretable. Two 'Attribute Named' categories that sit close together have similar profiles across 'Brand Chosen'. |
| Distance from a point of 'Brand Chosen' to a point of 'Attribute Named' | NOT interpretable as association strength. This is a symmetric map: each set of categories is scaled to its own inertia, so a short gap between a point of 'Brand Chosen' and a point of 'Attribute Named' does not mean they go together. This is the mistake almost every reader makes. |
| Direction from the origin | Interpretable. A category of 'Brand Chosen' and a category of 'Attribute Named' that lie in the same direction from the origin — a small angle at the origin — occur together more than independence predicts; opposite directions mean they occur together less. |
| Distance from the origin | Interpretable. The further a category sits from the origin, the more its profile departs from the average profile. A category at the origin is simply average. |
| How much of the map is real | Dimensions 1 and 2 carry 92.2% of the total inertia of 1.0175. What is not on this plane cannot be seen on it. |
| Points you should not interpret | Every plotted category has at least 80.3% of its variation captured by these two dimensions, so all drawn positions carry meaning. |
| Pooled categories | No categories were pooled: every level of 'Brand Chosen' and 'Attribute Named' carried at least 15 observations and is plotted on its own. |
| Method | Classical correspondence analysis: the standardized residual matrix of the 5 × 5 table is decomposed by singular value decomposition, and both sets of categories are drawn in principal coordinates. |
The short answer
Read direction from the origin to identify brand–attribute pairings: LuxeBrand and Prestigious point the same way (negative on dimension 1), confirming they co-occur. Distance between two brands, or between two attributes, is also interpretable. The one rule everyone breaks: distance between a brand and an attribute point is not a measure of association.
The detail
Within-set distance is interpretable: two brands sitting close together have similar attribute profiles. BudgetCo and Cheapest price both sit at positive dimension 1 (1.2035 and 1.1984); LuxeBrand and Prestigious sit at negative dimension 1 (−1.1648 and −1.1566). This shared direction across sets confirms over-representation: BudgetCo × Cheapest price has a standardized residual of 21.91; LuxeBrand × Prestigious has 22.52. Both are far above independence. Distance from the origin measures profile distinctiveness: BudgetCo at (1.2035, 0.6935) is furthest, most distinctive. Dimensions 1 and 2 carry 92.2% of the total inertia of 1.0175. No categories were pooled; all 10 are plotted. Method: classical correspondence analysis via singular value decomposition of the standardized residual matrix.
What this can't tell you
This is a symmetric map, so the gap between a 'Brand Chosen' point and an 'Attribute Named' point mixes two independent scales and is meaningless. Direction from the origin—not proximity—is the correct reading for cross-variable relationships. The analysis is observational and cannot establish causal direction.
Methodology
Statistical methodology and diagnostics for Correspondence Analysis
Statistical Method
Standard-library analysis: how do two categorical variables map together? Map two categorical columns — brands against attributes, segments against behaviours, products against complaint reasons — and get the classical correspondence analysis: the contingency table's total inertia with a chi-square test of independence, the dimension table showing how much of the association each dimension explains, row and column coordinates on the first two dimensions, the symmetric biplot with both sets of categories on one map, and per-category contribution and quality-of-representation (cos-squared) tables so you know which points are actually well enough represented to interpret. The report states, in your own column names, the rule almost every reader breaks: in a symmetric biplot the distance from a row point to a column point is not a measure of association.
- Each row is one observation of both categorical variables, or (with a count column mapped) one cell of an already-aggregated contingency table
- Both mapped columns have a modest number of repeated categories — at least three each after cleaning, and not an identifier
- Counts are non-negative and the table has no entirely empty row or column
- The chi-square p-value is an approximation that weakens when many expected cell counts fall below 5; the coordinates themselves do not depend on that approximation
- In the symmetric map produced here, the distance between a row-variable point and a column-variable point is NOT a measure of their association — only within-set distances and directions from the origin are interpretable
- A category whose cos-squared quality on the drawn plane is low can still appear far from the origin; its position is an artefact of dimensions that are not shown
- Only the first two dimensions are drawn, so any association living on later dimensions is invisible on the map — the dimension table reports how much that is
- Rare categories are pooled into 'Other' against a stated threshold, and the pooled point is a mixture of unlike categories rather than a category in its own right
Analysis Code
Complete R source code for this analysis
Correspondence Analysis — How Two Categorical Variables Map Together
Builds the two-way contingency table from two mapped categorical columns (or from a pre-aggregated count column), decomposes its chi-square residuals by singular value decomposition, and places both sets of categories on one map: the biplot.
Why This Method?
A chi-square test answers WHETHER two categorical variables are related. Correspondence analysis answers HOW: it splits the table's total inertia (chi-square divided by the sample size) into independent dimensions and gives every category a coordinate, so the pattern behind a significant test becomes readable instead of merely certain.
What This Analysis Covers
- Total inertia and the chi-square test of independence
- The dimension (scree) table with the share of inertia each explains
- Row and column coordinates on the first two dimensions
- The symmetric biplot with both sets of categories on one map
- Per-category contribution and quality of representation (cos-squared)
- The reading rules, including the row-to-column distance trap
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {row_var, col_var, count}. All narrative is derived from the user's own column names and computed values. The decomposition uses base svd only — no correspondence-analysis package is required.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Formatting helpers — prose must never carry e-notation or bare asterisks
Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# name_row / name_col / name_count $ humanized user column names
# has_count $ logical — pre-aggregated input used
# tab $ contingency table (rows x cols)
# n_obs $ total observations (sum of counts)
# levels_row / levels_col $ level labels in the table
# n_missing_row/_col $ blanks recoded to "Missing"
# n_lumped_row/_col $ levels folded into "Other"
# w_lumped_row/_col $ observations behind those levels
# rare_min $ the stated lumping threshold
# eig_df $ dimension/scree table
# n_dims / lam / total_inertia $ decomposition
# pct_plane $ % inertia on dimensions 1 and 2
# coords_row / coords_col $ principal coordinates (matrices)
# biplot_df $ dim_1, dim_2, point_type, category, ...
# point_quality_df $ per-category contribution + cos2
# assoc_df $ inertia / chi-square / Cramer's V table
# reading_df $ the reading rules, computed
# chi_stat/chi_df/chi_p $ chi-square test of independence
# cramers_v / strength_band $ effect size + band (sibling vocabulary)
# significant / map_interpretable $ logical
# pct_low_expected $ % of cells with expected count < 5
# top_over $ 1-row df — strongest over-representation
# same_direction / top_cos_angle $ biplot direction check for that cell
# low_quality_labels $ categories whose position is unreliable
# d1_neg/_pos, c1_neg/_pos $ the poles of dimension 1
# metrics / json_output
# === /SHARED EXPORTS ===Step 1: Discover the mapped columns
initial_rows <- nrow(df)
for (k in c("row_var", "col_var")) {
if (!k %in% names(df)) {
stop(sprintf("column_mapping must map '%s' to a categorical column.", k))
}
}
name_row <- humanize_semantic("row_var", col_map)
name_col <- humanize_semantic("col_var", col_map)
has_count <- "count" %in% names(df)
name_count <- if (has_count) humanize_semantic("count", col_map) else NA_character_Step 2: Weights — one per row, or the mapped count column
The count column is coerced with the 95% rule; a column that fails it is refused by name rather than silently treated as 1-per-row.
if (has_count) {
cv <- df[["count"]]
if (!is.numeric(cv)) {
conv <- suppressWarnings(as.numeric(as.character(cv)))
n_orig <- sum(!is.na(cv) & as.character(cv) != "")
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
cv <- conv
} else {
stop(sprintf(paste0("'%s' was mapped as the count column but it is not numeric ",
"(only %d of %d non-blank values convert to a number). ",
"Map a numeric count column, or leave it unmapped so that ",
"each row counts as one observation."),
name_count, sum(!is.na(conv)), n_orig))
}
}
cv <- as.numeric(cv)
cv[is.na(cv)] <- 0
if (any(cv < 0)) {
stop(sprintf("'%s' contains negative counts — a contingency table needs counts of zero or more.",
name_count))
}
w <- cv
} else {
w <- rep(1, initial_rows)
}
keep_rows <- w > 0
a_raw <- df[["row_var"]][keep_rows]
b_raw <- df[["col_var"]][keep_rows]
w <- w[keep_rows]
n_obs <- sum(w)Step 3: Minimum size — named for the user's own columns
if (n_obs < 30) {
stop(sprintf(paste0("Only %s observation(s) available across '%s' and '%s' — ",
"correspondence analysis needs at least 30. A table built from ",
"fewer observations gives coordinates that move substantially ",
"with a handful of rows."),
format(round(n_obs), big.mark = ","), name_row, name_col))
}Step 4: Identifier guard — a near-unique column is not a category
guard_identifier <- function(x, nm) {
nl <- length(unique(trimws(as.character(x))))
if (nl > 20 && nl > 0.5 * length(x)) {
stop(sprintf(paste0("'%s' has %s distinct values across %s rows — that is an ",
"identifier, not a category. Correspondence analysis needs two ",
"columns with a modest number of repeated categories."),
nm, format(nl, big.mark = ","), format(length(x), big.mark = ",")))
}
}
guard_identifier(a_raw, name_row)
guard_identifier(b_raw, name_col)Step 5: Clean and lump — blanks become "Missing"; rare levels become
"Other" against a stated threshold, because a category carried by a handful of observations lands far from the origin on noise alone and distorts the whole decomposition.
rare_min <- max(5, ceiling(0.01 * n_obs))
max_levels <- 8
prep_categorical <- function(x, wts, nm) {
x <- trimws(as.character(x))
blank <- is.na(x) | x == ""
n_missing <- sum(wts[blank])
x[blank] <- "Missing"
tw <- tapply(wts, x, sum)
tw <- tw[order(-tw)]
n_raw_levels <- length(tw)
rare <- names(tw)[tw < rare_min]
keep <- setdiff(names(tw), rare)
if (length(keep) > max_levels) {
rare <- c(rare, keep[(max_levels + 1):length(keep)])
keep <- keep[seq_len(max_levels)]
}
n_lumped <- length(rare)
w_lumped <- if (n_lumped > 0) sum(tw[rare]) else 0
if (n_lumped >= n_raw_levels) {
stop(sprintf(paste0("Every one of the %d categories in '%s' carries fewer than %d ",
"observations, so there is nothing left to compare once rare ",
"levels are pooled. Use a coarser grouping of '%s'."),
n_raw_levels, nm, rare_min, nm))
}
if (n_lumped > 0) x[x %in% rare] <- "Other"
tw2 <- tapply(wts, x, sum)
tw2 <- tw2[order(-tw2)]
lv <- names(tw2)
special <- intersect(c("Other", "Missing"), lv)
lv <- c(setdiff(lv, special), special)
list(x = factor(x, levels = lv), n_missing = n_missing,
n_lumped = n_lumped, w_lumped = w_lumped, n_raw_levels = n_raw_levels)
}
pa <- prep_categorical(a_raw, w, name_row)
pb <- prep_categorical(b_raw, w, name_col)Step 6: Degeneracy — a map needs at least a 3 x 3 table
A two-category column produces exactly one dimension, and a one-category column produces none; neither can be drawn as a two-dimensional map.
check_width <- function(p, nm) {
k <- nlevels(p$x)
if (k < 2) {
stop(sprintf(paste0("'%s' has only one distinct value (%s) after cleaning — ",
"correspondence analysis compares how categories differ from ",
"each other, so it needs at least three in each column."),
nm, levels(p$x)[1]))
}
if (k < 3) {
stop(sprintf(paste0("'%s' has only two categories (%s) after cleaning. A two-category ",
"column yields a single dimension, so there is no two-dimensional ",
"map to draw; test the association directly instead of mapping it."),
nm, oxford(levels(p$x))))
}
}
check_width(pa, name_row)
check_width(pb, name_col)
final_rows <- length(pa$x)
rows_removed <- initial_rows - final_rowsStep 7: Contingency table
tab <- tapply(w, list(pa$x, pb$x), sum)
tab[is.na(tab)] <- 0
tab <- as.matrix(tab)
dimnames(tab) <- list(levels(pa$x), levels(pb$x))Structural empties cannot enter the decomposition (their inverse mass is undefined); drop them and re-check the size.
keep_r <- rowSums(tab) > 0
keep_c <- colSums(tab) > 0
tab <- tab[keep_r, keep_c, drop = FALSE]
if (nrow(tab) < 3 || ncol(tab) < 3) {
stop(sprintf(paste0("The table of '%s' by '%s' collapsed to %d x %d once empty ",
"categories were removed — a map needs at least 3 x 3."),
name_row, name_col, nrow(tab), ncol(tab)))
}
levels_row <- rownames(tab)
levels_col <- colnames(tab)
n <- sum(tab)Step 8: Chi-square test of independence — the same test the
categorical-association tool runs, reported here so the map is never read without knowing whether there is anything to read.
chi <- suppressWarnings(chisq.test(tab, correct = FALSE))
chi_stat <- as.numeric(chi$statistic)
chi_df <- as.numeric(chi$parameter)
chi_p <- as.numeric(chi$p.value)
expected <- chi$expected
pct_low_expected <- 100 * mean(expected < 5)
significant <- !is.na(chi_p) && chi_p < 0.05Step 9: The decomposition — standardized residuals by base svd
P is the correspondence matrix, r and c the row and column masses, and S the matrix of standardized residuals whose squared singular values are the principal inertias. No correspondence-analysis package is used.
P <- tab / n
rm_ <- rowSums(P)
cm_ <- colSums(P)
S <- sweep(sweep(P - outer(rm_, cm_), 1, 1 / sqrt(rm_), "*"),
2, 1 / sqrt(cm_), "*")
sv <- svd(S)
n_dims <- min(nrow(tab), ncol(tab)) - 1
d <- sv$d[seq_len(n_dims)]
lam <- d^2
total_inertia <- sum(lam)Total inertia is chi-square divided by the sample size — this identity is what ties the map back to the test.
coords_row <- sweep(sv$u[, seq_len(n_dims), drop = FALSE] %*%
diag(d, n_dims, n_dims), 1, 1 / sqrt(rm_), "*")
coords_col <- sweep(sv$v[, seq_len(n_dims), drop = FALSE] %*%
diag(d, n_dims, n_dims), 1, 1 / sqrt(cm_), "*")
rownames(coords_row) <- levels_row
rownames(coords_col) <- levels_colSingular-vector signs are arbitrary; orient each dimension so its most extreme row category is positive, which makes the output reproducible.
for (k in seq_len(n_dims)) {
ix <- which_extreme(abs(coords_row[, k]), largest = TRUE)
if (!is.na(ix) && coords_row[ix, k] < 0) {
coords_row[, k] <- -coords_row[, k]
coords_col[, k] <- -coords_col[, k]
}
}Step 10: Contributions and quality of representation
Contribution: how much of a dimension's inertia one category creates. cos-squared: how much of a category's own distance from the average profile that dimension captures — the honest measure of whether the point's drawn position means anything.
contrib <- function(coords, mass) {
out <- matrix(NA_real_, nrow(coords), ncol(coords),
dimnames = dimnames(coords))
for (k in seq_len(ncol(coords))) {
if (is.finite(lam[k]) && lam[k] > 1e-12) {
out[, k] <- 100 * mass * coords[, k]^2 / lam[k]
}
}
out
}
cos2 <- function(coords) {
d2 <- rowSums(coords^2)
out <- matrix(NA_real_, nrow(coords), ncol(coords),
dimnames = dimnames(coords))
ok <- is.finite(d2) & d2 > 1e-12
if (any(ok)) out[ok, ] <- 100 * coords[ok, , drop = FALSE]^2 / d2[ok]
out
}
ctr_row <- contrib(coords_row, rm_)
ctr_col <- contrib(coords_col, cm_)
cos2_row <- cos2(coords_row)
cos2_col <- cos2(coords_col)
pct_dim <- 100 * lam / total_inertia
pct_plane <- if (n_dims >= 2) sum(pct_dim[1:2]) else pct_dim[1]
dim2_idx <- if (n_dims >= 2) 2 else 1
eig_df <- data.frame(
dimension = paste("Dimension", seq_len(n_dims)),
pct_inertia = round(pct_dim, 2),
eigenvalue = round(lam, 5),
cumulative_pct = round(cumsum(pct_dim), 2),
stringsAsFactors = FALSE
)Step 11: Biplot points — both sets of categories, one map
quality_plane_row <- if (n_dims >= 2) rowSums(cos2_row[, 1:2, drop = FALSE])
else cos2_row[, 1]
quality_plane_col <- if (n_dims >= 2) rowSums(cos2_col[, 1:2, drop = FALSE])
else cos2_col[, 1]
biplot_df <- rbind(
data.frame(
dim_1 = round(coords_row[, 1], 4),
dim_2 = round(coords_row[, dim2_idx], 4),
point_type = name_row,
category = levels_row,
mass_pct = round(100 * rm_, 2),
quality_pct = round(quality_plane_row, 1),
stringsAsFactors = FALSE
),
data.frame(
dim_1 = round(coords_col[, 1], 4),
dim_2 = round(coords_col[, dim2_idx], 4),
point_type = name_col,
category = levels_col,
mass_pct = round(100 * cm_, 2),
quality_pct = round(quality_plane_col, 1),
stringsAsFactors = FALSE
)
)
rownames(biplot_df) <- NULL
point_quality_df <- data.frame(
variable = biplot_df$point_type,
category = biplot_df$category,
mass_pct = biplot_df$mass_pct,
dim_1 = biplot_df$dim_1,
dim_2 = biplot_df$dim_2,
contribution_dim1_pct = round(c(ctr_row[, 1], ctr_col[, 1]), 1),
contribution_dim2_pct = round(c(ctr_row[, dim2_idx], ctr_col[, dim2_idx]), 1),
quality_pct = biplot_df$quality_pct,
stringsAsFactors = FALSE
)
point_quality_df$reliability <- ifelse(
is.na(point_quality_df$quality_pct), "not computable",
ifelse(point_quality_df$quality_pct >= 70, "well represented",
ifelse(point_quality_df$quality_pct >= 40, "partly represented",
"poorly represented — do not interpret its position")))
point_quality_df <- point_quality_df[order(-point_quality_df$quality_pct), ,
drop = FALSE]
rownames(point_quality_df) <- NULL
low_quality_labels <- point_quality_df$category[
!is.na(point_quality_df$quality_pct) & point_quality_df$quality_pct < 40]
min_quality <- if (all(is.na(point_quality_df$quality_pct))) NA_real_
else min(point_quality_df$quality_pct, na.rm = TRUE)Step 12: Poles of dimension 1 — what the main axis separates
pole <- function(coords) {
hi <- which_extreme(coords[, 1], largest = TRUE)
lo <- which_extreme(coords[, 1], largest = FALSE)
list(pos = if (is.na(hi)) NA_character_ else rownames(coords)[hi],
neg = if (is.na(lo)) NA_character_ else rownames(coords)[lo])
}
pr <- pole(coords_row)
pc <- pole(coords_col)Step 13: The strongest single over-representation — the sibling
tool's vocabulary (standardized residuals) reused deliberately.
obs_long <- as.data.frame(as.table(tab), stringsAsFactors = FALSE)
names(obs_long) <- c("level_row", "level_col", "observed")
exp_long <- as.data.frame(as.table(expected), stringsAsFactors = FALSE)
std_long <- as.data.frame(as.table(chi$stdres), stringsAsFactors = FALSE)
dev_all <- data.frame(
level_row = obs_long$level_row, level_col = obs_long$level_col,
cell = paste0(obs_long$level_row, " × ", obs_long$level_col),
observed = obs_long$observed,
expected = round(exp_long$Freq, 1),
std_residual = round(std_long$Freq, 2),
stringsAsFactors = FALSE
)
dev_all <- dev_all[is.finite(dev_all$std_residual), , drop = FALSE]
ix_over <- which_extreme(dev_all$std_residual, largest = TRUE)
top_over <- if (!is.na(ix_over) && dev_all$std_residual[ix_over] > 0)
dev_all[ix_over, , drop = FALSE] else NULLDirection check for that cell: in a symmetric map a row and a column category that go together point the same way from the origin. The cosine of the angle between the two position vectors is the computable form of that reading.
same_direction <- NA
top_cos_angle <- NA_real_
if (!is.null(top_over)) {
vr <- coords_row[top_over$level_row, c(1, dim2_idx)]
vc <- coords_col[top_over$level_col, c(1, dim2_idx)]
den <- sqrt(sum(vr^2)) * sqrt(sum(vc^2))
if (is.finite(den) && den > 1e-12) {
top_cos_angle <- sum(vr * vc) / den
same_direction <- top_cos_angle > 0
}
}Whether the two positive poles (and the two negative poles) genuinely co-occur more than independence predicts. Checked against the standardized residual of those two cells rather than asserted from the picture — the whole point of this tool is that the picture can mislead.
resid_of <- function(rl, cl) {
if (is.na(rl) || is.na(cl)) return(NA_real_)
ix <- which(dev_all$level_row == rl & dev_all$level_col == cl)
if (length(ix) == 1) dev_all$std_residual[ix] else NA_real_
}
pole_pos_resid <- resid_of(pr$pos, pc$pos)
pole_neg_resid <- resid_of(pr$neg, pc$neg)
poles_agree <- !is.na(pole_pos_resid) && !is.na(pole_neg_resid) &&
pole_pos_resid > 0 && pole_neg_resid > 0Step 14: Association strength — Cramer's V, same bands as the sibling
cramers_v <- sqrt(total_inertia / (min(dim(tab)) - 1))
strength_band <- if (is.na(cramers_v)) "unknown"
else if (cramers_v < 0.1) "negligible"
else if (cramers_v < 0.3) "weak"
else if (cramers_v < 0.5) "moderate"
else "strong"The map is only worth reading if the table departs from independence.
map_interpretable <- significant
assoc_df <- data.frame(
measure = c("Total inertia", "Chi-square statistic", "Degrees of freedom",
"P-value", "Cramér's V", "Dimensions available",
"Inertia on dimensions 1 and 2"),
value = c(fmt_num(total_inertia), fmt_num(chi_stat, 2),
format(chi_df), p_display(chi_p),
fmt_num(cramers_v, 3), format(n_dims), fmt_pct(pct_plane)),
interpretation = c(
paste0("Chi-square divided by the ", format(round(n), big.mark = ","),
" observations — the total amount of association the map splits up."),
"How far the observed table sits from the counts independence would predict.",
paste0("(", nrow(tab), " - 1) × (", ncol(tab), " - 1)."),
if (significant)
"Independence is rejected at the 0.05 level, so there is a real pattern to map."
else
"Independence is not rejected at the 0.05 level, so the map shows sampling noise.",
paste0("Association strength is ", strength_band,
" (0 = none, 1 = perfect; below 0.1 negligible, 0.1 to 0.3 weak, ",
"0.3 to 0.5 moderate, above 0.5 strong)."),
paste0("min(rows, columns) - 1 = ", n_dims,
" independent dimensions carry all of the inertia."),
paste0("The share of the association visible on the drawn map; the rest lives on ",
max(0, n_dims - 2), " dimension(s) that are not drawn.")
),
stringsAsFactors = FALSE
)Step 15: The reading rules, written with the user's own column names
unreliable_rule <- if (length(low_quality_labels) > 0) {
paste0(length(low_quality_labels), " categor",
if (length(low_quality_labels) == 1) "y is" else "ies are",
" represented by less than 40% on this plane(",
oxford(low_quality_labels),
"). Their drawn positions are mostly an artefact of dimensions that are ",
"not shown — do not read them.")
} else {
paste0("Every plotted category has at least ", fmt_pct(min_quality),
" of its variation captured by these two dimensions, so all drawn ",
"positions carry meaning.")
}
other_rule <- if (pa$n_lumped > 0 || pb$n_lumped > 0) {
paste0("The \"Other\" point is a pooled mixture of ",
pa$n_lumped + pb$n_lumped,
" rare categories, so its position is an average of unlike things and ",
"should not be interpreted as a category in its own right.")
} else {
paste0("No categories were pooled: every level of '", name_row, "' and '",
name_col, "' carried at least ", rare_min,
" observations and is plotted on its own.")
}
reading_df <- data.frame(
rule = c(
paste0("Distance between two '", name_row, "' points"),
paste0("Distance between two '", name_col, "' points"),
paste0("Distance from a point of '", name_row, "' to a point of '", name_col, "'"),
"Direction from the origin",
"Distance from the origin",
"How much of the map is real",
"Points you should not interpret",
"Pooled categories",
"Method"
),
detail = c(
paste0("Interpretable. Two '", name_row,
"' categories that sit close together have similar profiles across '",
name_col, "'."),
paste0("Interpretable. Two '", name_col,
"' categories that sit close together have similar profiles across '",
name_row, "'."),
paste0("NOT interpretable as association strength. This is a symmetric map: ",
"each set of categories is scaled to its own inertia, so a short gap ",
"between a point of '", name_row, "' and a point of '", name_col,
"' does not mean they go together. This is the mistake almost ",
"every reader makes."),
paste0("Interpretable. A category of '", name_row, "' and a category of '", name_col,
"' that lie in the same direction from the origin — a small ",
"angle at the origin — occur together more than independence predicts; ",
"opposite directions mean they occur together less."),
paste0("Interpretable. The further a category sits from the origin, the more ",
"its profile departs from the average profile. A category at the origin ",
"is simply average."),
paste0("Dimensions 1 and 2 carry ", fmt_pct(pct_plane),
" of the total inertia of ", fmt_num(total_inertia),
". What is not on this plane cannot be seen on it."),
unreliable_rule,
other_rule,
paste0("Classical correspondence analysis: the standardized residual matrix ",
"of the ", nrow(tab), " × ", ncol(tab),
" table is decomposed by singular value decomposition, and both sets ",
"of categories are drawn in principal coordinates.")
),
stringsAsFactors = FALSE
)
metrics <- list(
`Observations` = as.integer(round(n)),
`Table Size` = paste0(nrow(tab), " × ", ncol(tab)),
`Total Inertia` = round(total_inertia, 4),
`Chi-Square` = round(chi_stat, 2),
`P Value` = p_display(chi_p),
`Cramér's V` = round(cramers_v, 3),
`Dim 1+2 Inertia` = fmt_pct(pct_plane),
`Association` = if (significant) paste0("associated(", strength_band, ")")
else "not associated"
)
answer <- if (map_interpretable) {
paste0(
"Correspondence analysis of '", name_row, "' by '", name_col, "' over ",
format(round(n), big.mark = ","), " observations in a ", nrow(tab), " × ",
ncol(tab), " table: total inertia ", fmt_num(total_inertia),
" (chi-square ", fmt_num(chi_stat, 2), ", ", p_phrase(chi_p),
"; Cramér's V ", fmt_num(cramers_v, 3), ", ", strength_band,
"). Dimension 1 carries ", fmt_pct(pct_dim[1]),
" of that inertia and separates ", pr$neg, " from ", pr$pos, " on the '",
name_row, "' side and ", pc$neg, " from ", pc$pos, " on the '", name_col,
"' side; dimensions 1 and 2 together show ", fmt_pct(pct_plane), ".",
if (!is.null(top_over)) paste0(
" The strongest over-representation is ", top_over$cell, " (",
format(round(top_over$observed), big.mark = ","), " observed vs ",
format(top_over$expected, big.mark = ","), " expected).") else "",
" Read the map by direction from the origin: in a symmetric map the distance ",
"from a point of '", name_row, "' to a point of '", name_col,
"' is not a measure of how strongly they go together."
)
} else {
paste0(
"Correspondence analysis of '", name_row, "' by '", name_col, "' over ",
format(round(n), big.mark = ","), " observations in a ", nrow(tab), " × ",
ncol(tab), " table finds no association to map: total inertia is only ",
fmt_num(total_inertia), " and the chi-square test of independence does not ",
"reject independence(", p_phrase(chi_p), "; Cramér's V ",
fmt_num(cramers_v, 3), ", ", strength_band,
"). The coordinates still exist and are still plotted, but they describe ",
"sampling noise rather than structure and should not be interpreted."
)
}
json_output <- list(
answer = answer,
cards = lapply(
c("tldr", "overview", "preprocessing", "biplot", "dimension_summary",
"association_summary", "point_quality", "reading_the_map"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows,
rows_removed = rows_removed,
name_row = name_row, name_col = name_col, name_count = name_count,
has_count = has_count,
tab = tab, n_obs = n, levels_row = levels_row, levels_col = levels_col,
n_missing_row = pa$n_missing, n_missing_col = pb$n_missing,
n_lumped_row = pa$n_lumped, n_lumped_col = pb$n_lumped,
w_lumped_row = pa$w_lumped, w_lumped_col = pb$w_lumped,
n_raw_levels_row = pa$n_raw_levels, n_raw_levels_col = pb$n_raw_levels,
rare_min = rare_min, max_levels = max_levels,
eig_df = eig_df, n_dims = n_dims, lam = lam, pct_dim = pct_dim,
total_inertia = total_inertia, pct_plane = pct_plane,
coords_row = coords_row, coords_col = coords_col,
biplot_df = biplot_df, point_quality_df = point_quality_df,
assoc_df = assoc_df, reading_df = reading_df,
chi_stat = chi_stat, chi_df = chi_df, chi_p = chi_p,
pct_low_expected = pct_low_expected,
cramers_v = cramers_v, strength_band = strength_band,
significant = significant, map_interpretable = map_interpretable,
top_over = top_over, same_direction = same_direction,
top_cos_angle = top_cos_angle,
pole_pos_resid = pole_pos_resid, pole_neg_resid = pole_neg_resid,
poles_agree = poles_agree,
low_quality_labels = low_quality_labels, min_quality = min_quality,
d1_pos = pr$pos, d1_neg = pr$neg, c1_pos = pc$pos, c1_neg = pc$neg,
metrics = metrics, json_output = json_output
)
}Row and column contributions each sum to 100% within their own variable, so the top contributor is reported per variable — pooling the two would compare shares of two different totals.
top_of <- function(v) {
sub <- pq[pq$variable == v, , drop = FALSE]
sub[order(-sub$contribution_dim1_pct), , drop = FALSE][1, , drop = FALSE]
}
top_r <- top_of(shared$name_row)
top_c <- top_of(shared$name_col)
n_poor <- sum(!is.na(pq$quality_pct) & pq$quality_pct < 40)
list(
title = "Which Points Can Be Trusted",
description = "Per-category contribution to each dimension and quality of representation.",
text = paste0(
"Two numbers decide whether a point's position means anything. ",
"Contribution says how much of a dimension that category creates; the ",
"'", shared$name_row, "' categories account for 100% of each dimension ",
"between them and the '", shared$name_col,
"' categories independently account for another 100%, so they are read ",
"within a variable, not across the two. On dimension 1 that is ",
top_r$category, " at ", fmt_pct(top_r$contribution_dim1_pct), " of the '",
shared$name_row, "' side and ", top_c$category, " at ",
fmt_pct(top_c$contribution_dim1_pct), " of the '", shared$name_col,
"' side. Quality (cos-squared) says how much of a category's own ",
"distance from the average profile these two dimensions capture — ",
best$category, " is best represented at ", fmt_pct(best$quality_pct),
", while ", worst$category, " is worst at ", fmt_pct(worst$quality_pct), ". ",
if (n_poor > 0)
paste0(n_poor, " categor", if (n_poor == 1) "y falls" else "ies fall",
" below 40% and ", if (n_poor == 1) "is" else "are",
" marked as not interpretable: ",
if (n_poor == 1) "it lies" else "they lie",
" where the drawn plane happens to project ",
if (n_poor == 1) "it" else "them",
", not where ", if (n_poor == 1) "its" else "their",
" profile actually differs.")
else
paste0("No category falls below 40%, so every drawn position is a fair ",
"summary of that category's profile."),
" A category with low quality can still sit far from the origin on the ",
"map — that is exactly why the picture alone is not enough."
),
data = list(point_quality = pq)
)
}
# Card: reading_the_map (table)
card_reading_the_map <- function(shared, df, params) {
list(
title = "Reading the Biplot — and the One Rule Everyone Breaks",
description = paste0("What each distance and direction on the '",
shared$name_row, "' by '", shared$name_col,
"' map does and does not mean."),
text = paste0(
"The single most common error in reading a correspondence biplot is ",
"measuring how close a point of '", shared$name_row, "' sits to a point of '",
shared$name_col, "' and calling that the strength of their ",
"association. In this symmetric map it is not. Both sets are drawn in ",
"principal coordinates, each scaled to its own inertia, so the two clouds ",
"have no common yardstick: the gap between a point of '", shared$name_row,
"' and a point of '", shared$name_col,
"' mixes two different scales and is not a quantity. What IS ",
"interpretable is distance within each set, direction from the origin ",
"across the sets, and distance from the origin as departure from the ",
"average profile. ",
if (shared$map_interpretable && isTRUE(shared$poles_agree))
paste0("Applied here: ", shared$d1_pos, " and ", shared$c1_pos,
" both sit at the positive end of dimension 1 while ",
shared$d1_neg, " and ", shared$c1_neg,
" sit at the negative end. The table confirms what that shared ",
"direction implies — ", shared$d1_pos, " × ", shared$c1_pos,
" has a standardized residual of ", fmt_num(shared$pole_pos_resid, 2),
" and ", shared$d1_neg, " × ", shared$c1_neg, " one of ",
fmt_num(shared$pole_neg_resid, 2),
", both above what independence predicts. Read from the direction ",
"they share, not from how far apart they are drawn.")
else if (shared$map_interpretable)
paste0("Applied here with a caveat: ", shared$d1_pos, " and ", shared$c1_pos,
" sit at the positive end of dimension 1 and ", shared$d1_neg,
" and ", shared$c1_neg, " at the negative end, but the ",
"standardized residuals of those two cells(",
fmt_num(shared$pole_pos_resid, 2), " and ",
fmt_num(shared$pole_neg_resid, 2),
") do not both exceed what independence predicts. Dimension 1 is ",
"separating profiles here rather than pairing individual ",
"categories — check the deviations before reading any single pair ",
"off the picture.")
else
paste0("None of it applies here, because the chi-square test does not ",
"reject independence(", p_phrase(shared$chi_p),
"): there is no association for any reading rule to recover."),
" The rows below spell out each rule for your own columns."
),
data = list(reading_rules = shared$reading_df)
)
}