Executive Summary
Anomaly scan across 12 columns
The short answer
Of 5,000 credit card transactions, 249 (4.98%) are flagged as anomalous at the 99.9% threshold. The most extreme case is Row 2290, with an anomaly score of 1175.96—driven by V5 reaching 28.517, far above the typical value of −0.074.
The detail
The scan covered 12 columns and flagged rows with anomaly scores exceeding 32.91. Row 2290's score is 35.7 times the threshold, with V5 deviating 26.97 robust standard deviations above typical. The 249 flagged rows represent the right tail of the score distribution; the bulk of the 5,000 transactions cluster in the normal range.
What this can't tell you
This identifies statistical outliers but does not assess whether they represent fraud, error, or legitimate unusual activity. A transaction-level audit of the 249 flagged rows would be needed to determine which require investigation.
Analysis Overview
Multivariate anomaly scan across 12 columns and 5,000 observations.
The short answer
All 5,000 rows were scanned across 12 transaction features using multivariate distance, which flags rows that are unusual in their combination of values, not just extreme in one column. This method is robust to scale and correlation, so it catches hidden patterns that univariate screening would miss.
The detail
The analysis scored each of 5,000 rows by its squared Mahalanobis distance from the center of all 12 columns (V1–V12), with a flag threshold set at 32.91 (the 99.9% chi-square critical value for 12 degrees of freedom). Anomalies are then explained by their dominant feature—the column with the largest robust z-score (deviation in median absolute deviations) for that row, so ordinary outliers cannot distort the yardstick itself.
What this can't tell you
The method detects unusual patterns, not fraud or risk in isolation. A transaction can be multivariate-anomalous but operationally benign, or operationally risky but statistically unremarkable. To connect these scores to business outcomes, you would need to validate against known fraud cases or operational incidents in your data.
Data Quality
Column typing, imputation, and exclusions.
The short answer
All 5,000 rows were retained and usable; no rows were dropped. Missing values were filled with each column's median, a standard conservative choice that preserves the bulk of the distribution.
The detail
Initial load: 5,000 rows. Final dataset: 5,000 rows. Rows removed: 0. All mapped columns were numeric and non-constant, so no features were excluded from the scan. Missing values were imputed using the column median, which anchors the imputation to the typical value rather than the mean (which would be skewed by the outliers you are trying to find).
What this can't tell you
If your missing data is not random—for example, if missing V8 values cluster in a particular merchant category or time window—median imputation will smooth over that pattern. Consider flagging rows with imputed values separately if missingness itself is operationally meaningful.
Anomaly Score Distribution
Distribution of per-row anomaly scores with the flag threshold.
The short answer
The bulk of the 5,000 transactions cluster at low anomaly scores (most under 15), forming a dense normal cloud. A long right tail extends to extreme scores, with 249 rows (4.98%) crossing the threshold of 32.91. The maximum score, 1175.96, detaches sharply from the rest.
The detail
The distribution is heavily right-skewed. Typical scores in the main body range from 1.67 to about 15; the threshold sits at 32.91. Beyond the threshold, scores climb steeply: 45.49, 68.81, 104.41, 222.19, 307.62 (repeated six times), and finally 1175.96. The gap between the highest sub-threshold score and the lowest flagged score shows clear separation between normal and anomalous regimes.
What this can't tell you
Score magnitude alone does not indicate severity or business impact. A score of 307.62 is mathematically extreme but may represent a consistent pattern (note that six rows share this exact score), whereas 1175.96 is unique. Business context is required to prioritize which anomalies warrant investigation.
Top Anomalies
The most anomalous rows with the feature driving each one.
| Row ID | Anomaly Score | Dominant Feature | Dominant Value | Typical Value | Direction |
|---|---|---|---|---|---|
| Row 2290 | 1176 | V5 | 28.52 | -0.074 | above typical |
| Row 4308 | 547.6 | V2 | -17.76 | 0.157 | below typical |
| Row 2716 | 545.3 | V2 | -19.53 | 0.157 | below typical |
| Row 1999 | 540 | V8 | -41.04 | 0.046 | below typical |
| Row 4927 | 492.2 | V8 | -38.99 | 0.046 | below typical |
| Row 4597 | 320.1 | V8 | -28.76 | 0.046 | below typical |
| Row 12 | 307.6 | V8 | -37.35 | 0.046 | below typical |
| Row 73 | 307.6 | V8 | -37.35 | 0.046 | below typical |
| Row 235 | 307.6 | V8 | -37.35 | 0.046 | below typical |
| Row 1323 | 307.6 | V8 | -37.35 | 0.046 | below typical |
| Row 2397 | 307.6 | V8 | -37.35 | 0.046 | below typical |
| Row 4734 | 307.6 | V8 | -37.35 | 0.046 | below typical |
| Row 4057 | 293 | V2 | -18.62 | 0.157 | below typical |
| Row 442 | 246 | V8 | 20.01 | 0.046 | above typical |
| Row 2420 | 238.9 | V2 | -14.51 | 0.157 | below typical |
The short answer
The 15 most anomalous rows are driven by two dominant features: V8 appears in 10 of them, V2 in 4, and V5 in 1. V8 anomalies are almost entirely negative deviations (values around −37 to −28, vs. typical 0.046), while V2 anomalies are also negative (around −14 to −19, vs. typical 0.157). Row 2290 stands apart with V5 at 28.517—the only major positive outlier.
The detail
Row 2290 leads with score 1175.96 (V5 = 28.517, typical −0.074). Rows 4308 and 2716 follow with scores 547.59 and 545.33 (both V2, values −17.756 and −19.527). Rows 1999, 4927, and 4597 cluster around scores 540–320 (all V8, values −41 to −28). Six rows (12, 73, 235, 1323, 2397, 4734) share identical scores of 307.62 and identical V8 values of −37.353. Row 442 is the only other positive outlier (V8 = 20.007, typical 0.046, score 246.04).
What this can't tell you
This ranking identifies the most statistically extreme rows but does not confirm whether the six rows with identical scores and values represent duplicate records, a data entry error, or genuinely independent transactions. Checking for duplicates in the underlying data would clarify whether all 15 rows represent distinct anomalies or whether some are artifacts of data collection.
Anomaly Map
The dataset in 2D with anomalies highlighted.
The short answer
The 249 flagged anomalies scatter across different directions on the dataset's two main axes of variation, rather than clustering in one region. This pattern suggests independent, one-off outliers rather than a single systematic issue affecting a subset of transactions.
The detail
The dataset projects onto PC1 (53.2% of variance) and PC2 (10.5% of variance), together explaining 63.7% of total variation. Normal transactions cluster tightly near the origin; anomalies appear at the fringes in multiple directions. For example, some anomalies sit far out on PC1 (e.g., coordinates 19.253, −12.326 and 17.369, 3.854), while others deviate sharply on PC2 (e.g., 7.263, −16.125). No dense cluster of anomalies is visible in a single quadrant or direction.
What this can't tell you
Scattering in different directions means no single root cause is apparent from the covariance structure. To determine whether anomalies reflect different transaction types, merchant categories, or data-quality issues, you would need to cross-reference flagged rows with operational metadata (time, merchant, amount, etc.) not captured in these 12 features.
Which Columns Drive Anomalies
Per-column maximum robust deviation across the anomalous rows.
The short answer
V8 is by far the most extreme column, with a maximum robust z-score of 95.62 among anomalies. V7 ranks second at 45.92. These two columns account for the sharpest deviations, but all 12 columns contribute extreme values somewhere in the anomaly set.
The detail
Column ranking by maximum robust z-score across the 249 anomalies: V8 (95.62), V7 (45.92), V10 (30.58), V5 (26.97), V2 (25.71), V12 (21.1), V3 (20.01), V6 (19.29), V1 (15.57), V9 (13.37), V11 (9.71), V4 (8.71). V8 and V7 are outliers even among the anomaly drivers; the next tier (V10–V2) drops to the 20s and 30s. The tail (V11, V4) still reaches 9.71 and 8.71, confirming every column carries signal.
What this can't tell you
High robust z-scores in V8 and V7 do not explain why those columns are extreme—whether they reflect data-entry errors, rare legitimate transaction types, or systematic processing anomalies. A focused audit of V8 and V7 values in flagged rows, paired with their business context, would be the next step to classify and act on these deviations.
Anomaly Detection — Outlier Finder
Finds the unusual rows in a dataset across several numeric columns. Every row is scored by its Mahalanobis distance from the multivariate center of the mapped columns; rows beyond the 99.9% chi-square threshold are flagged, and each anomaly is explained by its dominant feature — the column with the largest robust z-score (median/MAD).
Why This Method?
Mahalanobis distance is scale-free and correlation-aware: a row can be flagged for an unusual COMBINATION of values even when no single column looks extreme. Robust per-column z-scores (median/MAD) resist the very outliers they are meant to find, and give each anomaly a plain-language explanation: which column, how far, and in which direction.
What This Analysis Covers
- Anomaly score per row + 99.9% threshold flags (and the top 1% by score)
- Top anomalies ranked and explained (dominant feature, value vs typical)
- Score distribution histogram and a 2D PCA anomaly map
- Per-column deviation profile across the anomalous rows
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {feature_1..feature_N}. All narrative is derived from the user's own column names and computed values.
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))Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# feature_names $ named character — semantic -> humanized names
# used_features $ character — semantic names used
# dropped_features $ character — excluded columns
# scores $ numeric — squared Mahalanobis distance per row
# score_method $ "mahalanobis" | "mahalanobis_ridge" | "max_robust_z"
# threshold $ numeric — 99.9% flag threshold on the score
# flag $ logical — anomaly flags per row
# n_anom / n_top1 $ counts: flagged rows, top-1%-by-score rows
# dom_idx $ integer — per-row dominant feature index (NA-safe)
# Z $ matrix — robust z per row x feature
# top_anomalies_df $ data.frame(row_id, anomaly_score, dominant_feature,
# dominant_value, typical_value, direction) — top 15
# score_distribution_df $ data.frame(anomaly_score) — <=5000 sample
# anomaly_map_df $ data.frame(pc1, pc2, status) — <=1000, all anomalies
# pc_var $ numeric(2) — % variance of PC1/PC2
# feature_deviations_df $ data.frame(feature, max_robust_z)
# extreme $ list — most extreme row (id, score, feature, ...)
# metrics / json_output
# === /SHARED EXPORTS ===Step 1: Discover mapped features
initial_rows <- nrow(df)
feat_cols <- grep("^feature_[0-9]+$", names(df), value = TRUE)
feat_cols <- feat_cols[order(as.integer(sub("^feature_", "", feat_cols)))]
if (length(feat_cols) < 2) {
stop("column_mapping must map at least two feature columns(feature_1, feature_2)")
}
feature_names <- setNames(humanize_semantic(feat_cols, col_map), feat_cols)Step 2: Coerce numeric (95% rule); impute median; drop unusable
dropped_features <- character(0)
for (fc in feat_cols) {
v <- df[[fc]]
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & as.character(v) != "")
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
df[[fc]] <- conv
} else {
dropped_features <- c(dropped_features, fc); next
}
}
v <- df[[fc]]
med <- median(v, na.rm = TRUE)
if (is.na(med)) { dropped_features <- c(dropped_features, fc); next }
v[is.na(v)] <- med
df[[fc]] <- v
if (isTRUE(var(v) == 0) || is.na(var(v))) {
dropped_features <- c(dropped_features, fc)
}
}
used_features <- setdiff(feat_cols, dropped_features)
if (length(used_features) < 2) {
stop(paste0("Anomaly detection needs at least two usable numeric columns; only ",
length(used_features), " remained after cleaning(",
paste(feature_names[used_features], collapse = ", "), ")."))
}
X <- as.matrix(df[, used_features, drop = FALSE])
final_rows <- nrow(X)
rows_removed <- initial_rows - final_rows
if (final_rows < 20) {
stop(sprintf("Only %d usable rows — anomaly detection needs at least 20.", final_rows))
}
k <- length(used_features)
hn <- unname(feature_names[used_features])Step 3: Robust z per column — (x - median) / (1.4826 * MAD),
MAD == 0 -> sd fallback, both 0 -> z = 0
robust_z <- function(v) {
med <- median(v)
s <- 1.4826 * median(abs(v - med))
if (!is.finite(s) || s == 0) s <- sd(v)
if (!is.finite(s) || s == 0) return(rep(0, length(v)))
(v - med) / s
}
Z <- vapply(seq_len(k), function(j) robust_z(X[, j]), numeric(final_rows))
if (is.null(dim(Z))) Z <- matrix(Z, nrow = final_rows)
colnames(Z) <- used_features
absZ <- abs(Z)Step 4: Anomaly score — squared Mahalanobis distance from the
multivariate center (ridge-regularized covariance if singular; fallback to per-row max |robust z| if both fail)
ctr <- colMeans(X)
S <- stats::cov(X)
score_method <- "mahalanobis"
scores <- tryCatch(
stats::mahalanobis(X, center = ctr, cov = S),
error = function(e) NULL
)
if (is.null(scores)) {
score_method <- "mahalanobis_ridge"
S_r <- S + diag(1e-6 * mean(diag(S)), k)
scores <- tryCatch(
stats::mahalanobis(X, center = ctr, cov = S_r),
error = function(e) NULL
)
}
if (is.null(scores)) {
score_method <- "max_robust_z"
scores <- apply(absZ, 1, function(r) {
ok <- r[is.finite(r)]
if (length(ok) == 0) 0 else max(ok)
})
}
scores[!is.finite(scores)] <- 0Step 5: Threshold flags — 99.9% chi-square on the squared distance
(5-robust-sigma when on the fallback score) + top 1% by score
threshold <- if (score_method == "max_robust_z") 5 else qchisq(0.999, df = k)
flag <- scores > threshold
n_anom <- sum(flag)
n_top1 <- max(1L, as.integer(ceiling(0.01 * final_rows)))
ord <- order(-scores)
top1_cutoff <- scores[ord[n_top1]]Step 6: Per-row dominant feature — max |robust z|, all-NA guarded
dom_idx <- vapply(seq_len(final_rows), function(i) {
r <- absZ[i, ]
ok <- which(is.finite(r))
if (length(ok) == 0) return(NA_integer_)
ok[which.max(r[ok])]
}, integer(1))Step 7: Top anomalies table — top 15 by score, explained
col_medians <- apply(X, 2, median)
top_idx <- head(ord, 15)
top_anomalies_df <- do.call(rbind, lapply(top_idx, function(i) {
j <- dom_idx[i]
if (is.na(j)) {
data.frame(row_id = paste0("Row ", i),
anomaly_score = round(scores[i], 2),
dominant_feature = "(not determinable)",
dominant_value = NA_real_, typical_value = NA_real_,
direction = "", stringsAsFactors = FALSE)
} else {
data.frame(
row_id = paste0("Row ", i),
anomaly_score = round(scores[i], 2),
dominant_feature = hn[j],
dominant_value = round(X[i, j], 3),
typical_value = round(col_medians[j], 3),
direction = if (X[i, j] >= col_medians[j]) "above typical" else "below typical",
stringsAsFactors = FALSE
)
}
}))
rownames(top_anomalies_df) <- NULLStep 8: Score distribution — all scores, <=5000 sampled
set.seed(42)
sd_idx <- if (final_rows > 5000) sample(final_rows, 5000) else seq_len(final_rows)
score_distribution_df <- data.frame(anomaly_score = round(scores[sd_idx], 3),
stringsAsFactors = FALSE)Step 9: Anomaly map — PCA projection to 2D (as standard_pca does),
<=1000 rows ALWAYS including every flagged anomaly
pca <- prcomp(X, center = TRUE, scale. = TRUE)
eig <- pca$sdev^2
pc_var <- round(100 * eig / sum(eig), 1)
if (length(pc_var) < 2) pc_var <- c(pc_var, 0)
anom_rows <- which(flag)
if (length(anom_rows) > 1000) anom_rows <- ord[seq_len(1000)][flag[ord[seq_len(1000)]]]
norm_rows <- setdiff(seq_len(final_rows), anom_rows)
budget <- max(0, 1000 - length(anom_rows))
set.seed(42)
if (length(norm_rows) > budget) norm_rows <- sample(norm_rows, budget)
map_rows <- sort(c(anom_rows, norm_rows))
anomaly_map_df <- data.frame(
pc1 = round(pca$x[map_rows, 1], 3),
pc2 = if (ncol(pca$x) >= 2) round(pca$x[map_rows, 2], 3) else 0,
status = ifelse(flag[map_rows], "anomaly", "normal"),
stringsAsFactors = FALSE
)
rownames(anomaly_map_df) <- NULLStep 10: Feature deviations — per-feature max |robust z| across the
anomalous rows (top 1% by score when nothing crosses the threshold)
dev_rows <- if (n_anom > 0) which(flag) else head(ord, n_top1)
feature_deviations_df <- data.frame(
feature = hn,
max_robust_z = vapply(seq_len(k), function(j) {
r <- absZ[dev_rows, j]
ok <- r[is.finite(r)]
if (length(ok) == 0) 0 else round(max(ok), 2)
}, numeric(1)),
stringsAsFactors = FALSE
)
feature_deviations_df <- feature_deviations_df[
order(-feature_deviations_df$max_robust_z), , drop = FALSE]
rownames(feature_deviations_df) <- NULLStep 11: Most extreme row + narrative anchors
ex_i <- ord[1]
ex_j <- dom_idx[ex_i]
extreme <- list(
row_id = paste0("Row ", ex_i),
score = round(scores[ex_i], 2),
feature = if (is.na(ex_j)) "(not determinable)" else hn[ex_j],
value = if (is.na(ex_j)) NA_real_ else round(X[ex_i, ex_j], 3),
typical = if (is.na(ex_j)) NA_real_ else round(col_medians[ex_j], 3),
direction = if (is.na(ex_j)) "" else
if (X[ex_i, ex_j] >= col_medians[ex_j]) "above" else "below",
robust_z = if (is.na(ex_j)) NA_real_ else round(Z[ex_i, ex_j], 2)
)
metrics <- list(
`Observations` = final_rows,
`Columns Scanned` = k,
`Anomalies Flagged` = as.integer(n_anom),
`Anomaly Rate %` = round(100 * n_anom / final_rows, 2),
`Score Threshold` = round(threshold, 2),
`Max Anomaly Score` = round(max(scores), 2),
`Most Extreme Row` = extreme$row_id
)
json_output <- list(
answer = paste0(
"Multivariate anomaly scan of ", format(final_rows, big.mark = ","),
" rows across ", k, " columns: ", n_anom, " row(s) flagged beyond the ",
"99.9% threshold(score > ", round(threshold, 2), "). The most extreme is ",
extreme$row_id, " (score ", extreme$score, "), driven by ",
extreme$feature,
if (!is.na(extreme$value)) paste0(" = ", extreme$value, " — ",
extreme$direction, " its typical value of ", extreme$typical) else "",
". Scoring method: ",
if (score_method == "max_robust_z") "per-row max robust z(covariance singular)"
else "squared Mahalanobis distance", "."
),
cards = lapply(
c("tldr", "overview", "preprocessing", "score_distribution",
"top_anomalies", "anomaly_map", "feature_deviations"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows,
rows_removed = rows_removed,
feature_names = feature_names, used_features = used_features,
dropped_features = dropped_features,
scores = scores, score_method = score_method,
threshold = threshold, flag = flag,
n_anom = n_anom, n_top1 = n_top1, top1_cutoff = top1_cutoff,
dom_idx = dom_idx, Z = Z,
top_anomalies_df = top_anomalies_df,
score_distribution_df = score_distribution_df,
anomaly_map_df = anomaly_map_df, pc_var = pc_var,
feature_deviations_df = feature_deviations_df,
extreme = extreme,
metrics = metrics, json_output = json_output
)
}Claims about ANOMALIES count flagged (above-threshold) rows only — the table also shows below-threshold rows, which are merely "most unusual".
flagged <- tdf[tdf$anomaly_score > shared$threshold, , drop = FALSE]
anomaly_note <- if (nrow(flagged) > 0) {
ftab <- sort(table(flagged$dominant_feature), decreasing = TRUE)
paste0(" Among the ", nrow(flagged), " flagged ",
if (nrow(flagged) == 1) "anomaly" else "anomalies",
", the most common dominant feature is ", names(ftab)[1],
" (", as.integer(ftab[1]), " of ", nrow(flagged), ").")
} else ""
text <- paste0(
"The ", nrow(tdf), " most anomalous rows, ranked by score. ",
n_flagged_shown, " of them exceed the 99.9% threshold(",
round(shared$threshold, 2), "). Each row is explained by its dominant ",
"feature — the column furthest from typical in robust terms — with its ",
"actual value against the column median. ",
names(dom_tab)[1], " appears as the dominant feature in ",
as.integer(dom_tab[1]), " of the ", nrow(tdf), " most unusual rows.",
anomaly_note
)
list(
title = "Top Anomalies",
description = "The most anomalous rows with the feature driving each one.",
text = text,
data = list(top_anomalies = tdf)
)
}
# Card: anomaly_map (scatter)
card_anomaly_map <- function(shared, df, params) {
amap <- shared$anomaly_map_df
n_anom_shown <- sum(amap$status == "anomaly")
anom_pts <- amap[amap$status == "anomaly", , drop = FALSE]
spread_note <- if (n_anom_shown >= 2) {
same_side_pc1 <- max(mean(anom_pts$pc1 > 0), mean(anom_pts$pc1 < 0))
if (same_side_pc1 >= 0.8)
"Most anomalies fall on the same side of the map — a systematic pattern rather than random noise."
else
"The anomalies scatter in different directions — they look like independent one-off outliers rather than one systematic issue."
} else if (n_anom_shown == 1) {
"The single flagged anomaly sits isolated from the main cloud."
} else {
"No rows crossed the threshold; the cloud below is the full(sampled) dataset."
}
text <- paste0(
"All rows projected onto the dataset's two main axes of variation ",
"(principal components), which together carry ",
round(shared$pc_var[1] + shared$pc_var[2], 1), "% of the variance. ",
"The main cloud is normal behavior; highlighted points are the ",
n_anom_shown, " flagged anomalies. ", spread_note,
" (Showing ", format(nrow(amap), big.mark = ","),
" rows; every flagged anomaly is included.)"
)
list(
title = "Anomaly Map",
description = "The dataset in 2D with anomalies highlighted.",
text = text,
chart_labels = list(
pc1 = paste0("PC1 — ", shared$pc_var[1], "% of variance"),
pc2 = paste0("PC2 — ", shared$pc_var[2], "% of variance")
),
data = list(anomaly_map = amap)
)
}
# Card: feature_deviations (horizontal_bar)
card_feature_deviations <- function(shared, df, params) {
fdf <- shared$feature_deviations_df
basis <- if (shared$n_anom > 0) {
paste0("the ", shared$n_anom, " flagged anomalous row(s)")
} else {
paste0("the top 1% of rows by score(nothing crossed the threshold)")
}
quiet <- fdf$feature[fdf$max_robust_z < 3]
text <- paste0(
"For each column, the largest robust z-score observed across ", basis,
" — how many robust standard deviations the worst value sits from that ",
"column's median. ", fdf$feature[1], " drives the most extreme ",
"deviations(max |z| = ", fdf$max_robust_z[1], ")",
if (nrow(fdf) > 1) paste0(", followed by ", fdf$feature[2],
" (", fdf$max_robust_z[2], ")") else "", ". ",
if (length(quiet) > 0)
paste0(paste(quiet, collapse = ", "),
ifelse(length(quiet) == 1, " stays", " stay"),
" below 3 robust sigmas even among the anomalies — ",
"the trouble is concentrated elsewhere.")
else
"Every scanned column contributes extreme values among the anomalies."
)
list(
title = "Which Columns Drive Anomalies",
description = "Per-column maximum robust deviation across the anomalous rows.",
text = text,
data = list(feature_deviations = fdf)
)
}