Standard Anomaly Detection
Executive Summary

Executive Summary

Anomaly scan across 12 columns

Observations
5000
Columns Scanned
12
Anomalies Flagged
249
Anomaly Rate %
4.98
Score Threshold
32.91
Max Anomaly Score
1175.96
Most Extreme Row
Row 2290
249 of 5,000 rows (4.98%) are anomalies at the 99.9% threshold (score > 32.91). The most extreme row is Row 2290 with an anomaly score of 1175.96 — 35.7x the threshold. It stands out because of V5: its value of 28.517 sits far above the typical value of -0.074 (26.97 robust standard deviations away).
What this means

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.

Overview

Analysis Overview

Multivariate anomaly scan across 12 columns and 5,000 observations.

N Observations5000
N Columns12
N Anomalies249
Score Threshold32.91
What this means

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 Preparation

Data Quality

Column typing, imputation, and exclusions.

Initial Rows5000
Final Rows5000
Rows Removed0
What this means

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.

Visualization

Anomaly Score Distribution

Distribution of per-row anomaly scores with the flag threshold.

What this means

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.

Data Table

Top Anomalies

The most anomalous rows with the feature driving each one.

Row IDAnomaly ScoreDominant FeatureDominant ValueTypical ValueDirection
Row 22901176V528.52-0.074above typical
Row 4308547.6V2-17.760.157below typical
Row 2716545.3V2-19.530.157below typical
Row 1999540V8-41.040.046below typical
Row 4927492.2V8-38.990.046below typical
Row 4597320.1V8-28.760.046below typical
Row 12307.6V8-37.350.046below typical
Row 73307.6V8-37.350.046below typical
Row 235307.6V8-37.350.046below typical
Row 1323307.6V8-37.350.046below typical
Row 2397307.6V8-37.350.046below typical
Row 4734307.6V8-37.350.046below typical
Row 4057293V2-18.620.157below typical
Row 442246V820.010.046above typical
Row 2420238.9V2-14.510.157below typical
What this means

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.

Visualization

Anomaly Map

The dataset in 2D with anomalies highlighted.

What this means

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.

Visualization

Which Columns Drive Anomalies

Per-column maximum robust deviation across the anomalous rows.

What this means

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.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

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)] <- 0

Step 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) <- NULL

Step 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) <- NULL

Step 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) <- NULL

Step 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&#x27;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&#x27;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)
  )
}
Your data has more stories to tell.Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing