## ----------------------------------------------------------------------------- #| label: setup #| include: false # The embedding step needs a local GGUF model; UMAP and HDBSCAN need uwot and # dbscan. A checker has none of these, so we gate the real pipeline and fall back # to an illustrative layout keyed off the sample's known topics. Point # RELM_DEMO_MODEL (or RELM_TEST_MODEL_QWEN) at a GGUF file, and install # uwot + dbscan, to render the map from real embeddings. model_path <- Sys.getenv("RELM_DEMO_MODEL", Sys.getenv("RELM_TEST_MODEL_QWEN", "")) have_model <- nzchar(model_path) && file.exists(model_path) have_uwot <- requireNamespace("uwot", quietly = TRUE) have_dbscan <- requireNamespace("dbscan", quietly = TRUE) run_real <- have_model && have_uwot && have_dbscan ## ----------------------------------------------------------------------------- #| label: data csv <- system.file("extdata", "abstracts-sample.csv", package = "relm") abstracts <- if (nzchar(csv)) { utils::read.csv(csv, stringsAsFactors = FALSE) } else { # fallback when the vignette is knitted from the source tree utils::read.csv(file.path("..", "inst", "extdata", "abstracts-sample.csv"), stringsAsFactors = FALSE) } nrow(abstracts) table(abstracts$label) ## ----------------------------------------------------------------------------- #| label: pipeline-real if (run_real) { library(relm) m <- llm(model_path) emb <- llm_embed(m, abstracts$text, pooling = "mean", normalize = TRUE) set.seed(20240707) coords <- uwot::umap(emb, n_neighbors = 15, min_dist = 0.1, n_components = 2, metric = "cosine", n_sgd_threads = 1) cluster <- dbscan::hdbscan(coords, minPts = 15)$cluster # 0 = noise # Name each cluster from its most central abstracts. ids <- sort(setdiff(unique(cluster), 0)) labels <- vapply(ids, function(g) { members <- which(cluster == g) cen <- colMeans(coords[members, , drop = FALSE]) medoid <- members[which.min(rowSums(sweep(coords[members, , drop = FALSE], 2, cen)^2))] prompt <- paste0( "Reply with a SHORT topic label of 2 to 4 words for these abstracts, ", "and nothing else.\n\n", paste0("- ", abstracts$text[medoid], collapse = "\n"), "\n\nTopic label:" ) trimws(gsub("[\"'`.]", "", llm_generate(m, prompt, max_tokens = 12, temperature = 0, seed = 1))) }, character(1)) names(labels) <- ids close(m) } ## ----------------------------------------------------------------------------- #| label: pipeline-illustrative if (isFALSE(run_real)) { # Illustrative layout (no model/uwot/dbscan at render time): eight Gaussian blobs # keyed off the sample's known topics, so the map matches what the real pipeline # produces. Run the chunk above on your own model for the genuine embedding map. set.seed(20240707) labs <- unique(abstracts$label) ang <- setNames(2 * pi * (seq_along(labs) - 1) / length(labs), labs) cx <- 6 * cos(ang[abstracts$label]); cy <- 6 * sin(ang[abstracts$label]) coords <- cbind(cx + rnorm(nrow(abstracts), 0, 0.8), cy + rnorm(nrow(abstracts), 0, 0.8)) cluster <- as.integer(factor(abstracts$label, levels = labs)) labels <- setNames(labs, seq_along(labs)) # a synthetic embedding (one direction per topic + noise) so the quality and # structure metrics below have something to read without a model D <- 24 dirs <- matrix(rnorm(length(labs) * D), length(labs), D) dirs <- dirs / sqrt(rowSums(dirs^2)) emb <- dirs[cluster, ] + matrix(rnorm(nrow(abstracts) * D, sd = 0.5), nrow(abstracts), D) emb <- emb / sqrt(rowSums(emb^2)) } ## ----------------------------------------------------------------------------- #| label: cluster-map #| fig-width: 8.5 #| fig-height: 6.5 #| fig-alt: "UMAP scatter of abstracts coloured into topic clusters with topic labels." ids <- sort(setdiff(unique(cluster), 0)) pal <- adjustcolor(grDevices::hcl.colors(length(ids), "Dark 3"), 0.6) col <- rep(adjustcolor("grey70", 0.45), length(cluster)) for (j in seq_along(ids)) col[cluster == ids[j]] <- pal[j] op <- par(mar = c(4.2, 4.2, 3, 1), mgp = c(2.5, 0.7, 0), las = 1) xr <- range(coords[, 1]); yr <- range(coords[, 2]) # pad so edge labels fit plot(coords, col = col, pch = 19, cex = 0.5, xlab = "UMAP 1", ylab = "UMAP 2", xlim = xr + c(-1, 1) * 0.10 * diff(xr), ylim = yr + c(-1, 1) * 0.06 * diff(yr), main = sprintf("Topic modelling without Python (%s)", if (run_real) "real embeddings" else "illustrative")) halo <- function(x, y, s) { o <- 0.006 * diff(par("usr")[1:2]) for (dx in c(-1, 1)) for (dy in c(-1, 1)) text(x + dx * o, y + dy * o, s, col = "white", font = 2) text(x, y, s, font = 2) } for (j in seq_along(ids)) { members <- which(cluster == ids[j]) cen <- colMeans(coords[members, , drop = FALSE]) med <- members[which.min(rowSums(sweep(coords[members, , drop = FALSE], 2, cen)^2))] halo(coords[med, 1], coords[med, 2], labels[[as.character(ids[j])]]) } par(op) ## ----------------------------------------------------------------------------- #| label: quality simplified_silhouette <- function(coords, cluster) { ids <- sort(setdiff(unique(cluster), 0)) cen <- t(vapply(ids, function(g) colMeans(coords[cluster == g, , drop = FALSE]), numeric(ncol(coords)))) d <- vapply(seq_along(ids), function(i) sqrt(rowSums(sweep(coords, 2, cen[i, ])^2)), numeric(nrow(coords))) sil <- rep(NA_real_, nrow(coords)) for (i in seq_along(ids)) { m <- which(cluster == ids[i]); a <- d[m, i]; b <- apply(d[m, -i, drop = FALSE], 1, min) sil[m] <- (b - a) / pmax(a, b) } sil } cohesion <- function(emb, members) { # = mean cosine of members to the centroid U <- emb[members, , drop = FALSE]; U <- U / sqrt(rowSums(U^2)) sqrt(sum(colMeans(U)^2)) } ids <- sort(setdiff(unique(cluster), 0)) sil <- simplified_silhouette(coords, cluster) quality <- data.frame( topic = labels[as.character(ids)], n = as.integer(table(cluster)[as.character(ids)]), silhouette = round(vapply(ids, function(g) mean(sil[cluster == g]), numeric(1)), 2), cohesion = round(vapply(ids, function(g) cohesion(emb, which(cluster == g)), numeric(1)), 2) ) quality[order(-quality$silhouette), ] ## ----------------------------------------------------------------------------- #| label: terms stopwords <- c("the","a","an","and","or","to","of","in","on","for","with","we","our", "that","this","is","are","be","by","as","from","using","study","results", "method","approach","paper","show","these","which","can","based","present") tokenize <- function(t) { w <- unlist(regmatches(tolower(t), gregexpr("[[:alpha:]]+", tolower(t)))) w <- w[nchar(w) >= 3]; w[!w %in% stopwords] } toks <- lapply(abstracts$text, tokenize) vocab <- sort(unique(unlist(toks[cluster %in% ids]))) counts <- vapply(ids, function(g) as.integer(table(factor(unlist(toks[cluster == g]), levels = vocab))), integer(length(vocab))) rownames(counts) <- vocab # vocabulary x topic counts yw <- rowSums(counts); alpha <- length(vocab) * yw / sum(yw) # informative prior a0 <- sum(alpha); N <- sum(counts) top_terms <- function(gi, n = 8) { y <- counts[, gi]; ni <- sum(y); r <- yw - y delta <- log((y + alpha) / (ni + a0 - y - alpha)) - log((r + alpha) / (N - ni + a0 - r - alpha)) z <- delta / sqrt(1 / (y + alpha) + 1 / (r + alpha)) names(sort(z, decreasing = TRUE))[seq_len(n)] } data.frame( topic = labels[as.character(ids)], distinctive_terms = vapply(seq_along(ids), function(gi) paste(top_terms(gi), collapse = ", "), character(1)) ) ## ----------------------------------------------------------------------------- #| label: structure #| fig-width: 8 #| fig-height: 4.5 #| fig-alt: "Dendrogram of topics clustered by centroid cosine similarity." centroids <- t(vapply(ids, function(g) colMeans(emb[cluster == g, , drop = FALSE]), numeric(ncol(emb)))) centroids <- centroids / sqrt(rowSums(centroids^2)) cosm <- pmin(pmax(tcrossprod(centroids), -1), 1) short <- function(s) ifelse(nchar(s) > 22, paste0(substr(s, 1, 19), "..."), s) hc <- hclust(as.dist(1 - cosm), method = "average") hc$labels <- short(labels[as.character(ids)]) op <- par(mar = c(2, 4, 3, 9), mgp = c(2.5, 0.7, 0)) plot(as.dendrogram(hc), horiz = TRUE, xlab = "1 - cosine", main = "Which topics are near each other") par(op)