blob: 2d686368c87ff09c1959ab3793cc9c835d9127da [file] [log] [blame]
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001#' @include logging.R
2setGeneric("collocationAnalysis", function(kco, ...) standardGeneric("collocationAnalysis"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02003
Marc Kupietz37f96072026-09-03 07:18:11 +02004#' Name of the attribute under which cache files record their analysis parameters
5#' @noRd
6collocationCacheAttribute <- "RKorAPClient.collocationAnalysis"
7
8#' Parameters that a cached collocation analysis was computed with
9#'
10#' Collected from the calling `collocationAnalysis()` frame, so that parameters
11#' added in the future are taken into account automatically. `kco` and `cacheAs`
12#' are excluded: the former is not a parameter of the analysis, the latter only
13#' says where to store it.
14#'
15#' @param frame environment of the `collocationAnalysis()` call
16#' @param dots arguments passed on to [collocationScoreQuery()]
17#' @param kco [KorAPConnection()] object
18#' @return list of parameters to store with, and compare against, a cache file
19#' @noRd
20collocationCacheParameters <- function(frame, dots, kco) {
21 parameterNames <- setdiff(
22 names(formals(sys.function(sys.parent()))),
23 c("kco", "cacheAs", "...")
24 )
25 list(
26 parameters = mget(parameterNames, envir = frame),
27 dots = dots,
28 # reusing one cache file for two KorAP instances is a mistake worth catching
29 apiUrl = kco@apiUrl,
30 # recorded for reference only, deliberately not compared: corpus updates
31 # should not invalidate a deliberately kept analysis
32 indexRevision = kco@indexRevision
33 )
34}
35
36#' Parameters in which a cached collocation analysis differs from the current call
37#'
38#' @param stored parameters recorded in the cache file
39#' @param current parameters of the current call
40#' @return names of the differing parameters, empty if the cache is still valid
41#' @noRd
42differingCollocationCacheParameters <- function(stored, current) {
43 differing <- character(0)
44
45 for (name in union(names(stored$parameters), names(current$parameters))) {
46 if (!identical(stored$parameters[[name]], current$parameters[[name]])) {
47 differing <- c(differing, name)
48 }
49 }
50 if (!identical(stored$dots, current$dots)) {
51 differing <- c(differing, "...")
52 }
53 if (!identical(stored$apiUrl, current$apiUrl)) {
54 differing <- c(differing, "KorAP instance")
55 }
56
57 differing
58}
59
Marc Kupietzdbd431a2021-08-29 12:17:45 +020060#' Collocation analysis
61#'
Marc Kupietza8c40f42025-06-24 15:49:52 +020062#' @family collocation analysis functions
Marc Kupietzdbd431a2021-08-29 12:17:45 +020063#' @aliases collocationAnalysis
64#'
65#' @description
Marc Kupietzdbd431a2021-08-29 12:17:45 +020066#'
67#' Performs a collocation analysis for the given node (or query)
68#' in the given virtual corpus.
69#'
70#' @details
71#' The collocation analysis is currently implemented on the client side, as some of the
72#' functionality is not yet provided by the KorAP backend. Mainly for this reason
73#' it is very slow (several minutes, up to hours), but on the other hand very flexible.
74#' You can, for example, perform the analysis in arbitrary virtual corpora, use complex node queries,
75#' and look for expression-internal collocates using the focus function (see examples and demo).
76#'
77#' To increase speed at the cost of accuracy and possible false negatives,
78#' you can decrease searchHitsSampleLimit and/or topCollocatesLimit and/or set exactFrequencies to FALSE.
79#'
Marc Kupietze7f0d682025-02-19 10:50:59 +010080#' Note that some outdated non-DeReKo back-ends might not yet support returning tokenized matches (warning issued).
81#' In this case, the client library will fall back to client-side tokenization which might be slightly less accurate.
82#' This might lead to false negatives and to frequencies that differ from corresponding ones acquired via the web
Marc Kupietzdbd431a2021-08-29 12:17:45 +020083#' user interface.
84#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +020085#'
Marc Kupietz67edcb52021-09-20 21:54:24 +020086#' @param lemmatizeNodeQuery if TRUE, node query will be lemmatized, i.e. `x -> [tt/l=x]`
Marc Kupietzdbd431a2021-08-29 12:17:45 +020087#' @param minOccur minimum absolute number of observed co-occurrences to consider a collocate candidate
88#' @param topCollocatesLimit limit analysis to the n most frequent collocates in the search hits sample
89#' @param searchHitsSampleLimit limit the size of the search hits sample
90#' @param stopwords vector of stopwords not to be considered as collocates
Marc Kupietz6bd9cad2024-12-18 15:57:26 +010091#' @param withinSpan KorAP span specification (see <https://korap.ids-mannheim.de/doc/ql/poliqarp-plus?embedded=true#spans>) for collocations to be searched within. Defaults to `base/s=s`.
Marc Kupietzdbd431a2021-08-29 12:17:45 +020092#' @param exactFrequencies if FALSE, extrapolate observed co-occurrence frequencies from frequencies in search hits sample, otherwise retrieve exact co-occurrence frequencies
93#' @param seed seed for random page collecting order
Marc Kupietz67edcb52021-09-20 21:54:24 +020094#' @param expand if TRUE, `node` and `vc` parameters are expanded to all of their combinations
Marc Kupietz7d400e02021-12-19 16:39:36 +010095#' @param maxRecurse apply collocation analysis recursively `maxRecurse` times
96#' @param addExamples If TRUE, examples for instances of collocations will be added in a column `example`. This makes a difference in particular if `node` is given as a lemma query.
Marc Kupietz2b0b0a12025-10-19 14:49:14 +020097#' @param thresholdScore association score function (see \code{\link{association-score-functions}}) to use for computing the threshold that is applied for recursive collocation analysis calls (only applied when \code{maxRecurse > 0})
98#' @param threshold minimum value of `thresholdScore` function call to apply collocation analysis recursively (only applied when \code{maxRecurse > 0})
Marc Kupietz7d400e02021-12-19 16:39:36 +010099#' @param localStopwords vector of stopwords that will not be considered as collocates in the current function call, but that will not be passed to recursive calls
Marc Kupietz47d0d2b2021-12-19 16:38:52 +0100100#' @param collocateFilterRegex allow only collocates matching the regular expression
Marc Kupietzde679ea2025-10-19 13:14:51 +0200101#' @param queryMissingScores if TRUE, attempt to retrieve corpus-based association scores for vc/collocate combinations that would otherwise be imputed, by re-querying the KorAP backend without applying the collocate frequency threshold
Marc Kupietz95253342026-08-31 10:18:43 +0200102#' @param missingScoreQuantile lower quantile (evaluated per association measure over the pooled result set) that anchors the adaptive floor used for imputing missing scores between virtual corpora; a robust spread is subtracted from this anchor so the imputed values stay at or below the weakest observed scores. Imputed cells are marked in the `imputed*` columns; see the section on interpreting multi-VC comparisons below
Marc Kupietze34a8be2025-10-17 20:13:42 +0200103#' @param vcLabel optional label override for the current virtual corpus (used internally when named VC collections are expanded)
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200104#' @param cacheAs path to an RDS file for caching the result. If the file already exists, the cached result is loaded and returned immediately without contacting the server. Otherwise the analysis is run normally and the result is saved to the file before returning. Defaults to \code{NULL} (no caching).
Marc Kupietz37f96072026-09-03 07:18:11 +0200105#'
106#' The analysis parameters are stored alongside the result. If they differ from
107#' those of the current call, the cached result would not be the one that was
108#' asked for, so it is recomputed and the file overwritten, with a warning
109#' naming the parameters that differ. Pass a different \code{cacheAs} file name
110#' to keep an existing analysis. Cache files written by RKorAPClient 1.3.0 do
111#' not contain the parameters yet and are used as they are.
Marc Kupietz67edcb52021-09-20 21:54:24 +0200112#' @param ... more arguments will be passed to [collocationScoreQuery()]
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200113#' @inheritParams collocationScoreQuery,KorAPConnection-method
Marc Kupietz130a2a22025-10-18 16:09:23 +0200114#' @return
115#' A tibble where each row represents a candidate collocate for the requested node.
116#' Columns include (depending on the selected association measures):
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200117#'
Marc Kupietz130a2a22025-10-18 16:09:23 +0200118#' \itemize{
119#' \item \code{node}, \code{collocate}, \code{vc}, \code{label}: identifiers for the query node, collocate, virtual corpus, and optional label.
120#' \item Frequency and contingency information such as \code{frequency}, \code{O}, \code{O1}, \code{O2}, \code{E}, \code{leftContextSize}, \code{rightContextSize}, and \code{w}.
121#' \item Association measures (e.g. \code{logDice}, \code{ll}, \code{mi}, ...), one column per requested scorer.
122#' \item Per-labelled association scores produced by multi-VC comparisons using the pattern \code{<measure>_<label>}.
123#' \item Ranks per label/measure with the pattern \code{rank_<label>_<measure>} (1 is best) and the corresponding percentile ranks \code{percentile_rank_<label>_<measure>}.
124#' \item Pairwise contrasts for two-label comparisons, e.g. \code{delta_<measure>}, \code{delta_rank_<measure>}, and \code{delta_percentile_rank_<measure>}.
Marc Kupietz09b1c082026-05-01 14:45:47 +0200125#' \item Summary columns describing the strongest labels per measure (\code{winner_*}, \code{runner_up_*}, \code{loser_*}, and \code{max_delta_*}), including winner/loser \code{webUIRequestUrl} columns. In multi-VC comparisons, missing per-label concordance URLs are derived from another available row URL for the same \code{node}/\code{collocate} by replacing the \code{cq} parameter with the target label's virtual corpus. Unsuffixed \code{winner_webUIRequestUrl} and \code{loser_webUIRequestUrl} columns are populated only when the score-based URL choices agree.
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +0200126#' \item \code{imputed_<label>}, \code{n_imputed}, and \code{imputed}: flags marking rows whose scores were not observed for some label but imputed (see \code{missingScoreQuantile}). Filter with \code{dplyr::filter(!imputed)} to keep only collocates attested in every compared virtual corpus.
Marc Kupietz130a2a22025-10-18 16:09:23 +0200127#' \item Optional helper columns such as \code{query}, \code{example}, or \code{url} when example retrieval is requested.
128#' }
Marc Kupietz95253342026-08-31 10:18:43 +0200129#' @section Interpreting multi-VC comparisons:
130#'
Marc Kupietzba15cff2026-08-31 10:19:56 +0200131#' `r lifecycle::badge("experimental")`
132#'
133#' The comparison columns produced when `vc` holds more than one virtual corpus
134#' are experimental: their names and semantics may still change in a future
135#' release without a deprecation cycle. Code that has to keep working across
136#' versions should select the columns it needs explicitly.
137#'
138#' They are an exploration aid, not a significance test. When reading them, keep
139#' three properties in mind.
Marc Kupietz95253342026-08-31 10:18:43 +0200140#'
141#' \strong{Imputed scores describe presence/absence, not contrast.} A collocate
142#' that passes the `minOccur` and `topCollocatesLimit` thresholds in one virtual
143#' corpus but not in another has no observed score for the latter. Such cells are
144#' imputed from a floor derived from the pooled result set (see
145#' `missingScoreQuantile`), so the corresponding `delta_*` and `max_delta_*`
146#' values measure the distance to that floor rather than an attested difference.
147#' The `imputed`, `n_imputed` and `imputed_<label>` columns mark these rows;
148#' `dplyr::filter(!imputed)` restricts the result to collocates attested
149#' everywhere, and `queryMissingScores = TRUE` replaces most imputed cells with
150#' scores actually retrieved from the backend.
151#'
152#' \strong{Imputed values are relative to one analysis.} The floor is computed
153#' from the scores present in the result at hand. Analysing a node on its own and
154#' analysing it together with other nodes therefore yield different imputed
155#' values, and deltas involving imputed cells are not comparable across separate
156#' calls. Deltas between observed scores are unaffected.
157#'
158#' \strong{Winners carry no uncertainty.} Unlike [ci()], which attaches
159#' confidence intervals to relative frequencies, the `winner_*` / `loser_*`
160#' columns simply order point estimates. A collocate wins by a hair on six
161#' occurrences exactly as decisively as one that wins by a wide margin on
162#' thousands. Consult the observed frequencies (`O`, `O1`, `O2`) and the
163#' `webUIRequestUrl` concordance links before drawing conclusions from a
164#' small difference.
165#'
166#' Note also that `rank_<label>_<measure>` and
167#' `percentile_rank_<label>_<measure>` are computed within each label, over that
168#' label's own candidate set. Candidate sets usually differ in size between
169#' virtual corpora, so rank-based deltas compare positions in populations of
170#' different sizes.
Marc Kupietzc4540a22025-10-14 17:39:53 +0200171#' @importFrom dplyr arrange desc slice_head bind_rows group_by mutate ungroup left_join select row_number all_of first
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200172#' @importFrom purrr pmap
Marc Kupietzc4540a22025-10-14 17:39:53 +0200173#' @importFrom tidyr expand_grid pivot_wider
174#' @importFrom rlang sym
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200175#'
176#' @examples
Marc Kupietz6ae76052021-09-21 10:34:00 +0200177#' \dontrun{
178#'
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200179#' # Find top collocates of "Packung" inside and outside the sports domain.
180#' KorAPConnection(verbose = TRUE) |>
181#' collocationAnalysis("Packung",
182#' vc = c("textClass=sport", "textClass!=sport"),
183#' leftContextSize = 1, rightContextSize = 1, topCollocatesLimit = 20
184#' ) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200185#' dplyr::filter(logDice >= 5)
186#' }
187#'
Marc Kupietz6ae76052021-09-21 10:34:00 +0200188#' \dontrun{
189#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200190#' # Identify the most prominent light verb construction with "in ... setzen".
191#' # Note that, currently, the use of focus function disallows exactFrequencies.
Marc Kupietz4cd066d2025-02-28 15:48:23 +0100192#' KorAPConnection(verbose = TRUE) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200193#' collocationAnalysis("focus(in [tt/p=NN] {[tt/l=setzen]})",
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200194#' leftContextSize = 1, rightContextSize = 0, exactFrequencies = FALSE, topCollocatesLimit = 20
195#' )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200196#' }
197#'
198#' @export
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200199setMethod(
200 "collocationAnalysis", "KorAPConnection",
201 function(kco,
202 node,
203 vc = "",
204 lemmatizeNodeQuery = FALSE,
205 minOccur = 5,
206 leftContextSize = 5,
207 rightContextSize = 5,
208 topCollocatesLimit = 200,
209 searchHitsSampleLimit = 20000,
210 ignoreCollocateCase = FALSE,
211 withinSpan = ifelse(exactFrequencies, "base/s=s", ""),
212 exactFrequencies = TRUE,
213 stopwords = append(RKorAPClient::synsemanticStopwords(), node),
214 seed = 7,
215 expand = length(vc) != length(node),
216 maxRecurse = 0,
217 addExamples = FALSE,
218 thresholdScore = "logDice",
219 threshold = 2.0,
220 localStopwords = c(),
221 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzde679ea2025-10-19 13:14:51 +0200222 queryMissingScores = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200223 missingScoreQuantile = 0.05,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200224 vcLabel = NA_character_,
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200225 cacheAs = NULL,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200226 ...) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200227 word <- frequency <- O <- NULL
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200228
Marc Kupietz37f96072026-09-03 07:18:11 +0200229 cacheParameters <- NULL
230 if (!is.null(cacheAs)) {
231 if (!grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
232 cacheAs <- paste0(cacheAs, ".rds")
233 }
234 cacheParameters <- collocationCacheParameters(environment(), list(...), kco)
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200235 }
236
237 if (!is.null(cacheAs) && file.exists(cacheAs)) {
Marc Kupietz37f96072026-09-03 07:18:11 +0200238 cached <- readRDS(cacheAs)
239 storedParameters <- attr(cached, collocationCacheAttribute)
240 attr(cached, collocationCacheAttribute) <- NULL
241
242 if (is.null(storedParameters)) {
243 # written before parameter checking existed, so there is nothing to check
244 log_info(kco@verbose, sprintf(
245 "Loading collocation analysis from cache (written without parameters): %s\n", cacheAs
246 ))
247 return(cached)
248 }
249
250 differing <- differingCollocationCacheParameters(storedParameters, cacheParameters)
251 if (length(differing) == 0) {
252 log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
253 return(cached)
254 }
255
256 warning(
257 sprintf(
258 paste0(
259 "Cache file '%s' was created with different parameters (%s) and is recomputed and overwritten.\n",
260 "Pass a different cacheAs file name to keep the cached analysis."
261 ),
262 cacheAs, paste(differing, collapse = ", ")
263 ),
264 call. = FALSE
265 )
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200266 }
267
Marc Kupietzb2862d42025-10-18 10:17:49 +0200268 if (!exactFrequencies && (!is.na(withinSpan) && !is.null(withinSpan) && nzchar(withinSpan))) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200269 stop(sprintf("Not empty withinSpan (='%s') requires exactFrequencies=TRUE", withinSpan), call. = FALSE)
270 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200271
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200272 warnIfNotAuthorized(kco)
Marc Kupietz581a29b2021-09-04 20:51:04 +0200273
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200274 if (lemmatizeNodeQuery) {
275 node <- lemmatizeWordQuery(node)
276 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200277
Marc Kupietze34a8be2025-10-17 20:13:42 +0200278 vcNames <- names(vc)
Marc Kupietze34a8be2025-10-17 20:13:42 +0200279 if (is.null(vcNames)) {
280 vcNames <- rep(NA_character_, length(vc))
Marc Kupietze34a8be2025-10-17 20:13:42 +0200281 }
282
283 label_lookup <- NULL
Marc Kupietzb2862d42025-10-18 10:17:49 +0200284 if (!is.null(names(vc)) && length(vc) > 0) {
285 raw_names <- names(vc)
286 if (any(!is.na(raw_names) & raw_names != "")) {
287 label_lookup <- stats::setNames(raw_names, vc)
288 }
Marc Kupietze34a8be2025-10-17 20:13:42 +0200289 }
290
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200291 result <- if (length(node) > 1 || length(vc) > 1) {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200292 grid <- if (expand) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200293 tmp_grid <- tidyr::expand_grid(node = node, idx = seq_along(vc))
294 tmp_grid$vc <- vc[tmp_grid$idx]
295 tmp_grid$vcLabel <- vcNames[tmp_grid$idx]
296 tmp_grid[, c("node", "vc", "vcLabel"), drop = FALSE]
Marc Kupietze34a8be2025-10-17 20:13:42 +0200297 } else {
298 tibble(node = node, vc = vc, vcLabel = vcNames)
299 }
300
301 multi_result <- purrr::pmap(grid, function(node, vc, vcLabel, ...) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200302 collocationAnalysis(kco,
303 node = node,
304 vc = vc,
305 minOccur = minOccur,
306 leftContextSize = leftContextSize,
307 rightContextSize = rightContextSize,
308 topCollocatesLimit = topCollocatesLimit,
309 searchHitsSampleLimit = searchHitsSampleLimit,
310 ignoreCollocateCase = ignoreCollocateCase,
311 withinSpan = withinSpan,
312 exactFrequencies = exactFrequencies,
313 stopwords = stopwords,
314 addExamples = TRUE,
315 localStopwords = localStopwords,
316 seed = seed,
317 expand = expand,
Marc Kupietz9894a372025-10-18 14:51:29 +0200318 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200319 queryMissingScores = queryMissingScores,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200320 collocateFilterRegex = collocateFilterRegex,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200321 vcLabel = vcLabel,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200322 ...
323 )
324 }) |>
Marc Kupietze31322e2025-10-17 18:55:36 +0200325 bind_rows()
326
327 if (!"vc" %in% names(multi_result) || nrow(multi_result) == 0) {
328 multi_result
329 } else {
Marc Kupietzde679ea2025-10-19 13:14:51 +0200330 if (queryMissingScores) {
331 multi_result <- backfill_missing_scores(
332 multi_result,
333 grid = grid,
334 kco = kco,
335 ignoreCollocateCase = ignoreCollocateCase,
336 ...
337 )
338 }
339
Marc Kupietze34a8be2025-10-17 20:13:42 +0200340 if (!"label" %in% names(multi_result)) {
341 multi_result$label <- NA_character_
342 }
343
344 if (!is.null(label_lookup)) {
345 override <- unname(label_lookup[multi_result$vc])
346 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
347 if (any(missing_idx)) {
348 multi_result$label[missing_idx] <- override[missing_idx]
349 }
350 }
351
352 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
353 if (any(missing_idx)) {
354 multi_result$label[missing_idx] <- queryStringToLabel(multi_result$vc[missing_idx])
355 }
356
Marc Kupietze31322e2025-10-17 18:55:36 +0200357 multi_result |>
Marc Kupietz9894a372025-10-18 14:51:29 +0200358 add_multi_vc_comparisons(
Marc Kupietz424cb782026-08-31 10:19:29 +0200359 missingScoreQuantile = missingScoreQuantile,
360 verbose = kco@verbose
Marc Kupietz9894a372025-10-18 14:51:29 +0200361 )
Marc Kupietze31322e2025-10-17 18:55:36 +0200362 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200363 } else {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200364 if ((is.na(vcLabel) || vcLabel == "") && length(vcNames) >= 1) {
365 vcLabel <- vcNames[1]
366 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200367
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200368 set.seed(seed)
369 candidates <- collocatesQuery(
370 kco,
371 node,
372 vc = vc,
373 minOccur = minOccur,
374 leftContextSize = leftContextSize,
375 rightContextSize = rightContextSize,
376 searchHitsSampleLimit = searchHitsSampleLimit,
377 ignoreCollocateCase = ignoreCollocateCase,
378 stopwords = append(stopwords, localStopwords),
Marc Kupietzb2862d42025-10-18 10:17:49 +0200379 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200380 ...
381 )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200382
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200383 if (nrow(candidates) > 0) {
384 candidates <- candidates |>
385 filter(frequency >= minOccur) |>
386 slice_head(n = topCollocatesLimit)
387 collocationScoreQuery(
388 kco,
389 node = node,
390 collocate = candidates$word,
391 vc = vc,
392 leftContextSize = leftContextSize,
393 rightContextSize = rightContextSize,
394 observed = if (exactFrequencies) NA else candidates$frequency,
395 ignoreCollocateCase = ignoreCollocateCase,
396 withinSpan = withinSpan,
397 ...
398 ) |>
399 filter(O >= minOccur) |>
400 dplyr::arrange(dplyr::desc(logDice))
401 } else {
402 tibble()
403 }
404 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200405
406 if (!is.na(vcLabel) && vcLabel != "" && "label" %in% names(result)) {
407 result$label <- rep(vcLabel, nrow(result))
408 }
409
410 threshold_col <- thresholdScore
411 if (maxRecurse > 0 && nrow(result) > 0 && threshold_col %in% names(result)) {
412 threshold_values <- result[[threshold_col]]
413 eligible_idx <- which(!is.na(threshold_values) & threshold_values >= threshold)
414 if (length(eligible_idx) > 0) {
415 recurseWith <- result[eligible_idx, , drop = FALSE]
416 result <- collocationAnalysis(
417 kco,
418 node = paste0("(", buildCollocationQuery(
419 removeWithinSpan(recurseWith$node, withinSpan),
420 recurseWith$collocate,
421 leftContextSize = leftContextSize,
422 rightContextSize = rightContextSize,
423 withinSpan = ""
424 ), ")"),
425 vc = vc,
426 minOccur = minOccur,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200427 leftContextSize = leftContextSize,
428 rightContextSize = rightContextSize,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200429 withinSpan = withinSpan,
430 maxRecurse = maxRecurse - 1,
431 stopwords = stopwords,
432 localStopwords = recurseWith$collocate,
433 exactFrequencies = exactFrequencies,
434 searchHitsSampleLimit = searchHitsSampleLimit,
435 topCollocatesLimit = topCollocatesLimit,
436 addExamples = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200437 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200438 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200439 queryMissingScores = queryMissingScores,
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200440 thresholdScore = thresholdScore,
441 threshold = threshold,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200442 vcLabel = vcLabel
443 ) |>
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200444 bind_rows(result)
445
446 if (threshold_col %in% names(result)) {
447 threshold_values <- result[[threshold_col]]
448 keep_idx <- is.na(threshold_values) | threshold_values >= threshold
449 result <- result[keep_idx, , drop = FALSE]
450 }
451
452 result <- result |>
Marc Kupietzb2862d42025-10-18 10:17:49 +0200453 filter(O >= minOccur) |>
454 dplyr::arrange(dplyr::desc(logDice))
455 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200456 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200457
458 if (addExamples && nrow(result) > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200459 result$query <- buildCollocationQuery(
460 result$node,
461 result$collocate,
462 leftContextSize = leftContextSize,
463 rightContextSize = rightContextSize,
464 withinSpan = withinSpan
465 )
466 result$example <- findExample(
467 kco,
468 query = result$query,
469 vc = result$vc
470 )
471 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200472
Marc Kupietz0a292632025-10-19 14:04:36 +0200473 if (!is.null(withinSpan) && !is.na(withinSpan) && nzchar(withinSpan) &&
474 nrow(result) > 0 &&
475 "webUIRequestUrl" %in% names(result) &&
476 "query" %in% names(result)) {
477 candidate_rows <- which(!is.na(result$node) &
478 !grepl("focus\\(", result$node, perl = TRUE) &
479 !is.na(result$query) & nzchar(result$query))
480
481 if (length(candidate_rows) > 0) {
482 focused_queries <- vapply(
483 result$query[candidate_rows],
484 inject_focus_into_query,
485 character(1)
486 )
487
488 changed <- focused_queries != result$query[candidate_rows]
489 if (any(changed)) {
490 indices <- candidate_rows[changed]
491 vc_values <- as.character(result$vc)
492 vc_values[is.na(vc_values)] <- ""
493
494 result$webUIRequestUrl[indices] <- mapply(
495 function(new_query, vc_value) {
496 buildWebUIRequestUrlFromString(
497 kco@KorAPUrl,
498 new_query,
499 vc = vc_value,
500 ql = "poliqarp"
501 )
502 },
503 focused_queries[changed],
504 vc_values[indices],
505 USE.NAMES = FALSE
506 )
507 }
508 }
509 }
510
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200511 if (!is.null(cacheAs)) {
512 log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", cacheAs))
Marc Kupietz37f96072026-09-03 07:18:11 +0200513 # only the stored copy carries the parameters, so that the returned value
514 # is the same whether it was cached or not
515 cachedResult <- result
516 attr(cachedResult, collocationCacheAttribute) <- cacheParameters
517 saveRDS(cachedResult, cacheAs)
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200518 }
519
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200520 result
521 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200522)
523
Marc Kupietz76b05592021-12-19 16:26:15 +0100524# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +0100525removeWithinSpan <- function(query, withinSpan) {
526 if (withinSpan == "") {
527 return(query)
528 }
529 needle <- sprintf("^\\(contains\\(<%s>, ?(.*)\\){2}$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200530 res <- gsub(needle, "\\1", query)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100531 needle <- sprintf("^contains\\(<%s>, ?(.*)\\)$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200532 res <- gsub(needle, "\\1", res)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100533 return(res)
534}
535
Marc Kupietzde679ea2025-10-19 13:14:51 +0200536backfill_missing_scores <- function(result,
537 grid,
538 kco,
539 ignoreCollocateCase,
540 ...) {
541 if (!"vc" %in% names(result) || !"node" %in% names(result) || !"collocate" %in% names(result)) {
542 return(result)
543 }
544
545 if (nrow(result) == 0) {
546 return(result)
547 }
548
Marc Kupietz9c53e412026-06-21 12:13:44 +0200549 distinct_pairs <- dplyr::distinct(
550 result,
551 .data$node,
552 .data$collocate
553 )
Marc Kupietzde679ea2025-10-19 13:14:51 +0200554 if (nrow(distinct_pairs) == 0) {
555 return(result)
556 }
557
558 collocates_by_node <- split(as.character(distinct_pairs$collocate), distinct_pairs$node)
559 if (length(collocates_by_node) == 0) {
560 return(result)
561 }
562
563 required_combinations <- unique(as.data.frame(grid[, c("node", "vc", "vcLabel")], drop = FALSE))
564 for (i in seq_len(nrow(required_combinations))) {
565 node_value <- required_combinations$node[i]
566 vc_value <- required_combinations$vc[i]
567
568 collocate_pool <- collocates_by_node[[node_value]]
569 if (is.null(collocate_pool) || length(collocate_pool) == 0) {
570 next
571 }
572
573 existing_idx <- result$node == node_value & result$vc == vc_value
574 existing_collocates <- unique(as.character(result$collocate[existing_idx]))
575 missing_collocates <- setdiff(unique(collocate_pool), existing_collocates)
576 missing_collocates <- missing_collocates[!is.na(missing_collocates) & nzchar(missing_collocates)]
577
578 if (length(missing_collocates) == 0) {
579 next
580 }
581
582 context_rows <- result[result$node == node_value & result$vc == vc_value, , drop = FALSE]
583 if (nrow(context_rows) == 0) {
584 context_rows <- result[result$node == node_value, , drop = FALSE]
585 }
586
587 left_size <- context_rows$leftContextSize[!is.na(context_rows$leftContextSize)][1]
588 if (is.na(left_size) || length(left_size) == 0) {
589 left_size <- result$leftContextSize[!is.na(result$leftContextSize)][1]
590 }
591 if (is.na(left_size) || length(left_size) == 0) {
592 left_size <- 5
593 }
594
595 right_size <- context_rows$rightContextSize[!is.na(context_rows$rightContextSize)][1]
596 if (is.na(right_size) || length(right_size) == 0) {
597 right_size <- result$rightContextSize[!is.na(result$rightContextSize)][1]
598 }
599 if (is.na(right_size) || length(right_size) == 0) {
600 right_size <- 5
601 }
602
603 within_span_value <- ""
604 if ("query" %in% names(context_rows)) {
605 query_candidate <- context_rows$query[!is.na(context_rows$query) & nzchar(context_rows$query)][1]
606 if (!is.na(query_candidate) && nzchar(query_candidate)) {
607 match_one <- regexec("^\\(*contains\\(<([^>]+)>,", query_candidate)
608 matches <- regmatches(query_candidate, match_one)
609 if (length(matches) >= 1 && length(matches[[1]]) >= 2) {
610 within_span_value <- matches[[1]][2]
611 }
612 }
613 }
614
615 new_rows <- collocationScoreQuery(
616 kco,
617 node = node_value,
618 collocate = missing_collocates,
619 vc = vc_value,
620 leftContextSize = left_size,
621 rightContextSize = right_size,
622 ignoreCollocateCase = ignoreCollocateCase,
623 withinSpan = within_span_value,
624 ...
625 )
626
627 if (nrow(new_rows) == 0) {
628 next
629 }
630
631 if (!is.null(required_combinations$vcLabel[i]) && !is.na(required_combinations$vcLabel[i]) && required_combinations$vcLabel[i] != "" && "label" %in% names(new_rows)) {
632 new_rows$label <- required_combinations$vcLabel[i]
633 }
634
635 result <- dplyr::bind_rows(result, new_rows)
636 }
637
638 result
639}
640
Marc Kupietz0a292632025-10-19 14:04:36 +0200641inject_focus_into_query <- function(query) {
642 if (is.null(query) || is.na(query)) {
643 return(query)
644 }
645
646 trimmed <- trimws(query)
647 if (!nzchar(trimmed)) {
648 return(query)
649 }
650
651 if (!grepl("^contains\\(<[^>]+>", trimmed, perl = TRUE)) {
652 return(query)
653 }
654
655 if (grepl("focus\\(", trimmed, perl = TRUE)) {
656 return(query)
657 }
658
659 pattern <- "^contains\\(<([^>]+)>\\s*,\\s*\\((.*)\\)\\)\\s*$"
660 matches <- regexec(pattern, trimmed, perl = TRUE)
661 components <- regmatches(trimmed, matches)
662 if (length(components) == 0 || length(components[[1]]) < 3) {
663 return(query)
664 }
665
666 span <- components[[1]][2]
667 inner <- components[[1]][3]
668 parts <- strsplit(inner, "\\|", perl = TRUE)[[1]]
669 parts <- trimws(parts)
670 parts <- parts[nzchar(parts)]
671
672 if (length(parts) == 0) {
673 return(query)
674 }
675
676 focused <- paste0("focus({", parts, "})")
677 combined <- paste(focused, collapse = " | ")
678
679 sprintf("contains(<%s>, (%s))", span, combined)
680}
681
Marc Kupietz424cb782026-08-31 10:19:29 +0200682add_multi_vc_comparisons <- function(result, missingScoreQuantile = 0.05, verbose = FALSE) {
Marc Kupietz09b1c082026-05-01 14:45:47 +0200683 label <- node <- collocate <- vc <- webUIRequestUrl <- NULL
Marc Kupietzc4540a22025-10-14 17:39:53 +0200684
685 if (!"label" %in% names(result) || dplyr::n_distinct(result$label) < 2) {
686 return(result)
687 }
688
689 numeric_cols <- names(result)[vapply(result, is.numeric, logical(1))]
690 non_score_cols <- c("N", "O", "O1", "O2", "E", "w", "leftContextSize", "rightContextSize", "frequency")
691 score_cols <- setdiff(numeric_cols, non_score_cols)
692
693 if (length(score_cols) == 0) {
694 return(result)
695 }
696
Marc Kupietz9894a372025-10-18 14:51:29 +0200697 compute_score_floor <- function(values) {
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200698 # Estimate a conservative floor so missing scores can be imputed without favoring any label
Marc Kupietz9894a372025-10-18 14:51:29 +0200699 finite_values <- values[is.finite(values)]
700 if (length(finite_values) == 0) {
701 return(0)
702 }
703
704 prob <- min(max(missingScoreQuantile, 0), 0.5)
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200705 # Use a lower quantile as the anchor to stay near the weakest attested scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200706 q_val <- suppressWarnings(stats::quantile(finite_values,
707 probs = prob,
708 names = FALSE,
709 type = 7
710 ))
711
712 if (!is.finite(q_val)) {
713 q_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
714 }
715
716 min_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
717 if (!is.finite(min_val)) {
718 min_val <- 0
719 }
720
721 spread_candidates <- c(
722 suppressWarnings(stats::IQR(finite_values, na.rm = TRUE, type = 7)),
723 stats::sd(finite_values, na.rm = TRUE),
724 abs(q_val) * 0.1,
725 abs(min_val - q_val)
726 )
727 spread_candidates <- spread_candidates[is.finite(spread_candidates)]
728
729 spread <- 0
730 if (length(spread_candidates) > 0) {
731 spread <- max(spread_candidates)
732 }
733 if (!is.finite(spread) || spread == 0) {
734 spread <- max(abs(q_val), abs(min_val), 1e-06)
735 }
736
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200737 # Step away from the anchor by a robust spread estimate to avoid ties with real scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200738 candidate <- q_val - spread
739 if (!is.finite(candidate)) {
740 candidate <- min_val
741 }
742
743 floor_value <- suppressWarnings(min(c(candidate, min_val), na.rm = TRUE))
744 if (!is.finite(floor_value)) {
745 floor_value <- min_val
746 }
747 if (!is.finite(floor_value)) {
748 floor_value <- 0
749 }
750
751 floor_value
752 }
753
754 score_replacements <- stats::setNames(
755 vapply(score_cols, function(col) {
756 compute_score_floor(result[[col]])
757 }, numeric(1)),
758 score_cols
759 )
760
Marc Kupietz7b7a73b2026-08-31 10:31:27 +0200761 # The pivots below keep only the first row per node/collocate/label. Duplicates do occur
762 # legitimately (e.g. the same collocate found at several context positions), but silently
763 # discarding all but one of them would misrepresent the comparison, so say so.
764 comparison_keys <- paste(result$node, result$collocate, result$label, sep = "\r")
765 duplicate_keys <- unique(comparison_keys[duplicated(comparison_keys)])
766 if (length(duplicate_keys) > 0) {
767 warning(
768 sprintf(
769 paste0(
770 "%d node/collocate/label combination(s) occur more than once; only the first row ",
771 "of each is used for the multi-VC comparison columns. Consider ",
772 "mergeDuplicateCollocates() to combine context positions before comparing."
773 ),
774 length(duplicate_keys)
775 ),
776 call. = FALSE
777 )
778 }
779
Marc Kupietzc4540a22025-10-14 17:39:53 +0200780 comparison <- result |>
Marc Kupietz28a29842025-10-18 12:25:09 +0200781 dplyr::select(node, collocate, label, dplyr::all_of(score_cols)) |>
782 tidyr::pivot_wider(
Marc Kupietzc4540a22025-10-14 17:39:53 +0200783 names_from = label,
Marc Kupietz28a29842025-10-18 12:25:09 +0200784 values_from = dplyr::all_of(score_cols),
Marc Kupietzc4540a22025-10-14 17:39:53 +0200785 names_glue = "{.value}_{make.names(label)}",
786 values_fn = dplyr::first
787 )
788
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200789 raw_labels <- unique(result$label)
790 labels <- make.names(raw_labels)
791 label_map <- stats::setNames(raw_labels, labels)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200792 vc_map <- result |>
793 dplyr::select(label, vc) |>
794 dplyr::filter(!is.na(label), label != "") |>
795 dplyr::distinct(label, .keep_all = TRUE)
796 vc_map <- stats::setNames(vc_map$vc, make.names(vc_map$label))
797
798 replace_web_ui_cq <- function(url, vc_value) {
799 if (length(url) == 0 || is.na(url) || url == "") {
800 return(NA_character_)
801 }
802 if (length(vc_value) == 0 || is.na(vc_value)) {
803 vc_value <- ""
804 }
805 encoded_vc <- urltools::url_encode(enc2utf8(as.character(vc_value)))
806 if (grepl("([?&]cq=)[^&]*", url, perl = TRUE)) {
807 return(sub("([?&]cq=)[^&]*", paste0("\\1", encoded_vc), url, perl = TRUE))
808 }
809 if (encoded_vc == "") {
810 return(url)
811 }
812 paste0(url, ifelse(grepl("\\?", url), "&", "?"), "cq=", encoded_vc)
813 }
814
815 if ("webUIRequestUrl" %in% names(result)) {
816 url_data <- result |>
817 dplyr::select(node, collocate, label, webUIRequestUrl) |>
818 tidyr::pivot_wider(
819 names_from = label,
820 values_from = webUIRequestUrl,
821 names_glue = "webUIRequestUrl_{make.names(label)}",
822 values_fn = dplyr::first
823 )
824
825 comparison <- dplyr::left_join(comparison, url_data, by = c("node", "collocate"))
826
827 url_cols <- paste0("webUIRequestUrl_", labels)
828 present_url_cols <- intersect(url_cols, names(comparison))
829 fallback_urls <- vapply(seq_len(nrow(comparison)), function(i) {
830 urls <- unlist(comparison[i, present_url_cols, drop = FALSE], use.names = FALSE)
831 urls <- as.character(urls)
832 urls <- urls[!is.na(urls) & urls != ""]
833 if (length(urls) == 0) {
834 NA_character_
835 } else {
836 urls[1]
837 }
838 }, character(1))
839
840 for (safe_label in labels) {
841 url_col <- paste0("webUIRequestUrl_", safe_label)
842 if (!url_col %in% names(comparison)) {
843 comparison[[url_col]] <- NA_character_
844 }
845 missing_urls <- is.na(comparison[[url_col]]) | comparison[[url_col]] == ""
846 if (any(missing_urls)) {
847 comparison[[url_col]][missing_urls] <- vapply(
848 fallback_urls[missing_urls],
849 replace_web_ui_cq,
850 character(1),
851 vc_value = vc_map[[safe_label]]
852 )
853 }
854 }
855 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200856
Marc Kupietz28a29842025-10-18 12:25:09 +0200857 rank_data <- result |>
858 dplyr::distinct(node, collocate)
859
860 for (i in seq_along(raw_labels)) {
861 raw_lab <- raw_labels[i]
862 safe_lab <- labels[i]
863 label_df <- result[result$label == raw_lab, c("node", "collocate", score_cols), drop = FALSE]
864 if (nrow(label_df) == 0) {
865 next
866 }
867 label_df <- dplyr::distinct(label_df)
868 rank_tbl <- label_df[, c("node", "collocate"), drop = FALSE]
869 for (col in score_cols) {
870 rank_col_name <- paste0("rank_", safe_lab, "_", col)
Marc Kupietz130a2a22025-10-18 16:09:23 +0200871 percentile_col_name <- paste0("percentile_rank_", safe_lab, "_", col)
Marc Kupietz28a29842025-10-18 12:25:09 +0200872 values <- label_df[[col]]
873 ranks <- rep(NA_real_, length(values))
Marc Kupietz130a2a22025-10-18 16:09:23 +0200874 percentiles <- rep(NA_real_, length(values))
Marc Kupietz28a29842025-10-18 12:25:09 +0200875 valid_idx <- which(!is.na(values))
876 if (length(valid_idx) > 0) {
877 ranks[valid_idx] <- rank(-values[valid_idx], ties.method = "first")
Marc Kupietz130a2a22025-10-18 16:09:23 +0200878 total <- length(valid_idx)
879 percentiles[valid_idx] <- 1 - (ranks[valid_idx] - 1) / total
Marc Kupietz28a29842025-10-18 12:25:09 +0200880 }
881 rank_tbl[[rank_col_name]] <- ranks
Marc Kupietz130a2a22025-10-18 16:09:23 +0200882 rank_tbl[[percentile_col_name]] <- percentiles
Marc Kupietz28a29842025-10-18 12:25:09 +0200883 }
884 rank_data <- dplyr::left_join(rank_data, rank_tbl, by = c("node", "collocate"))
885 }
886
887 comparison <- dplyr::left_join(comparison, rank_data, by = c("node", "collocate"))
888
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +0200889 # Record which label/measure cells are absent *before* any imputation happens below.
890 # Deltas computed from imputed cells reflect presence/absence of the collocate in a
891 # virtual corpus, not a measured contrast, so users need to be able to tell them apart.
892 imputed_flags <- lapply(labels, function(safe_label) {
893 label_score_cols <- intersect(paste0(score_cols, "_", safe_label), names(comparison))
894 if (length(label_score_cols) == 0) {
895 return(rep(FALSE, nrow(comparison)))
896 }
897 Reduce(`|`, lapply(label_score_cols, function(col) is.na(comparison[[col]])))
898 })
899 names(imputed_flags) <- paste0("imputed_", labels)
900
Marc Kupietz28a29842025-10-18 12:25:09 +0200901 rank_replacements <- numeric(0)
902 rank_column_names <- grep("^rank_", names(comparison), value = TRUE)
903 if (length(rank_column_names) > 0) {
904 rank_replacements <- stats::setNames(
905 vapply(rank_column_names, function(col) {
906 col_values <- comparison[[col]]
907 valid_values <- col_values[!is.na(col_values)]
908 if (length(valid_values) == 0) {
909 nrow(comparison) + 1
910 } else {
911 suppressWarnings(max(valid_values, na.rm = TRUE)) + 1
912 }
913 }, numeric(1)),
914 rank_column_names
915 )
916 }
917
Marc Kupietz130a2a22025-10-18 16:09:23 +0200918 percentile_replacements <- numeric(0)
919 percentile_column_names <- grep("^percentile_rank_", names(comparison), value = TRUE)
920 if (length(percentile_column_names) > 0) {
921 percentile_replacements <- stats::setNames(
922 rep(0, length(percentile_column_names)),
923 percentile_column_names
924 )
925 }
926
Marc Kupietz28a29842025-10-18 12:25:09 +0200927 collapse_label_values <- function(indices, safe_labels_vec) {
928 if (length(indices) == 0) {
929 return(NA_character_)
930 }
931 labs <- label_map[safe_labels_vec[indices]]
932 fallback <- safe_labels_vec[indices]
933 labs[is.na(labs) | labs == ""] <- fallback[is.na(labs) | labs == ""]
934 labs <- labs[!is.na(labs) & labs != ""]
935 if (length(labs) == 0) {
936 return(NA_character_)
937 }
938 paste(unique(labs), collapse = ", ")
939 }
940
Marc Kupietz09b1c082026-05-01 14:45:47 +0200941 collapse_url_values <- function(indices, url_values) {
942 if (length(indices) == 0 || is.null(url_values)) {
943 return(NA_character_)
944 }
945 urls <- as.character(url_values[indices])
946 urls <- urls[!is.na(urls) & urls != ""]
947 if (length(urls) == 0) {
948 return(NA_character_)
949 }
950 paste(unique(urls), collapse = ", ")
951 }
952
Marc Kupietzc4540a22025-10-14 17:39:53 +0200953 if (length(labels) == 2) {
Marc Kupietz9894a372025-10-18 14:51:29 +0200954 fill_scores <- function(x, y, measure_col) {
955 replacement <- score_replacements[[measure_col]]
956 fallback_min <- suppressWarnings(min(c(x, y), na.rm = TRUE))
957 if (!is.finite(fallback_min)) {
958 fallback_min <- 0
Marc Kupietzc4540a22025-10-14 17:39:53 +0200959 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200960 if (!is.null(replacement) && is.finite(replacement)) {
961 replacement <- min(replacement, fallback_min)
962 } else {
963 replacement <- fallback_min
964 }
965 if (!is.finite(replacement)) {
966 replacement <- 0
967 }
968 if (any(is.na(x))) {
969 x[is.na(x)] <- replacement
970 }
971 if (any(is.na(y))) {
972 y[is.na(y)] <- replacement
973 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200974 list(x = x, y = y)
975 }
976
Marc Kupietz130a2a22025-10-18 16:09:23 +0200977 fill_percentiles <- function(x, y, left_pct_col, right_pct_col) {
978 replacement_left <- percentile_replacements[[left_pct_col]]
979 if (is.null(replacement_left) || !is.finite(replacement_left)) {
980 replacement_left <- 0
981 }
982 replacement_right <- percentile_replacements[[right_pct_col]]
983 if (is.null(replacement_right) || !is.finite(replacement_right)) {
984 replacement_right <- 0
985 }
986 if (any(is.na(x))) {
987 x[is.na(x)] <- replacement_left
988 }
989 if (any(is.na(y))) {
990 y[is.na(y)] <- replacement_right
991 }
992 list(x = x, y = y)
993 }
994
Marc Kupietz28a29842025-10-18 12:25:09 +0200995 fill_ranks <- function(x, y, left_rank_col, right_rank_col) {
996 fallback <- nrow(comparison) + 1
997 replacement_left <- rank_replacements[[left_rank_col]]
998 if (is.null(replacement_left) || !is.finite(replacement_left)) {
999 replacement_left <- fallback
Marc Kupietzc4540a22025-10-14 17:39:53 +02001000 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001001 replacement_right <- rank_replacements[[right_rank_col]]
1002 if (is.null(replacement_right) || !is.finite(replacement_right)) {
1003 replacement_right <- fallback
1004 }
1005 if (any(is.na(x))) {
1006 x[is.na(x)] <- replacement_left
1007 }
1008 if (any(is.na(y))) {
1009 y[is.na(y)] <- replacement_right
1010 }
Marc Kupietzc4540a22025-10-14 17:39:53 +02001011 list(x = x, y = y)
1012 }
1013
1014 left_label <- labels[1]
1015 right_label <- labels[2]
1016
1017 for (col in score_cols) {
1018 left_col <- paste0(col, "_", left_label)
1019 right_col <- paste0(col, "_", right_label)
1020 if (!all(c(left_col, right_col) %in% names(comparison))) {
1021 next
1022 }
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001023 filled <- fill_scores(comparison[[left_col]], comparison[[right_col]], col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001024 comparison[[left_col]] <- filled$x
1025 comparison[[right_col]] <- filled$y
Marc Kupietzc4540a22025-10-14 17:39:53 +02001026 comparison[[paste0("delta_", col)]] <- filled$x - filled$y
Marc Kupietz28a29842025-10-18 12:25:09 +02001027 rank_left <- paste0("rank_", left_label, "_", col)
1028 rank_right <- paste0("rank_", right_label, "_", col)
1029 if (all(c(rank_left, rank_right) %in% names(comparison))) {
1030 filled_rank <- fill_ranks(
1031 comparison[[rank_left]],
1032 comparison[[rank_right]],
1033 rank_left,
1034 rank_right
1035 )
1036 comparison[[paste0("delta_rank_", col)]] <- filled_rank$x - filled_rank$y
1037 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001038 pct_left <- paste0("percentile_rank_", left_label, "_", col)
1039 pct_right <- paste0("percentile_rank_", right_label, "_", col)
1040 if (all(c(pct_left, pct_right) %in% names(comparison))) {
1041 filled_pct <- fill_percentiles(
1042 comparison[[pct_left]],
1043 comparison[[pct_right]],
1044 pct_left,
1045 pct_right
1046 )
1047 comparison[[paste0("delta_percentile_rank_", col)]] <- filled_pct$x - filled_pct$y
1048 }
Marc Kupietzc4540a22025-10-14 17:39:53 +02001049 }
1050 }
1051
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001052 for (col in score_cols) {
1053 value_cols <- paste0(col, "_", labels)
1054 existing <- value_cols %in% names(comparison)
1055 if (!any(existing)) {
1056 next
1057 }
1058 value_cols <- value_cols[existing]
1059 safe_labels <- labels[existing]
1060
1061 score_values <- comparison[, value_cols, drop = FALSE]
1062
1063 winner_label_col <- paste0("winner_", col)
1064 winner_value_col <- paste0("winner_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001065 winner_url_col <- paste0("winner_", col, "_webUIRequestUrl")
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001066 runner_label_col <- paste0("runner_up_", col)
1067 runner_value_col <- paste0("runner_up_", col, "_value")
Marc Kupietzb2862d42025-10-18 10:17:49 +02001068 loser_label_col <- paste0("loser_", col)
1069 loser_value_col <- paste0("loser_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001070 loser_url_col <- paste0("loser_", col, "_webUIRequestUrl")
Marc Kupietzb2862d42025-10-18 10:17:49 +02001071 max_delta_col <- paste0("max_delta_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001072 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1073 has_urls <- all(url_cols %in% names(comparison))
1074 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001075
1076 if (nrow(score_values) == 0) {
1077 comparison[[winner_label_col]] <- character(0)
1078 comparison[[winner_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001079 if (has_urls) {
1080 comparison[[winner_url_col]] <- character(0)
1081 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001082 comparison[[runner_label_col]] <- character(0)
1083 comparison[[runner_value_col]] <- numeric(0)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001084 comparison[[loser_label_col]] <- character(0)
1085 comparison[[loser_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001086 if (has_urls) {
1087 comparison[[loser_url_col]] <- character(0)
1088 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001089 comparison[[max_delta_col]] <- numeric(0)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001090 next
1091 }
1092
1093 score_matrix <- as.matrix(score_values)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001094 storage.mode(score_matrix) <- "numeric"
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001095
Marc Kupietzb2862d42025-10-18 10:17:49 +02001096 n_rows <- nrow(score_matrix)
1097 winner_labels <- rep(NA_character_, n_rows)
1098 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001099 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001100 runner_labels <- rep(NA_character_, n_rows)
1101 runner_values <- rep(NA_real_, n_rows)
1102 loser_labels <- rep(NA_character_, n_rows)
1103 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001104 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001105 max_deltas <- rep(NA_real_, n_rows)
1106
Marc Kupietzb2862d42025-10-18 10:17:49 +02001107 if (n_rows > 0) {
1108 for (i in seq_len(n_rows)) {
1109 numeric_row <- as.numeric(score_matrix[i, ])
1110 if (all(is.na(numeric_row))) {
1111 next
1112 }
1113
Marc Kupietz9894a372025-10-18 14:51:29 +02001114 replacement <- score_replacements[[col]]
1115 fallback_min <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1116 if (!is.finite(fallback_min)) {
1117 fallback_min <- 0
Marc Kupietzb2862d42025-10-18 10:17:49 +02001118 }
Marc Kupietz9894a372025-10-18 14:51:29 +02001119 if (!is.null(replacement) && is.finite(replacement)) {
1120 replacement <- min(replacement, fallback_min)
1121 } else {
1122 replacement <- fallback_min
1123 }
1124 if (!is.finite(replacement)) {
1125 replacement <- 0
1126 }
1127 if (any(is.na(numeric_row))) {
1128 numeric_row[is.na(numeric_row)] <- replacement
1129 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001130 score_matrix[i, ] <- numeric_row
1131
1132 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1133 max_idx <- which(numeric_row == max_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001134 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001135 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001136 if (has_urls) {
1137 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1138 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001139
1140 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1141 if (length(unique_vals) >= 2) {
1142 runner_val <- unique_vals[2]
1143 runner_idx <- which(numeric_row == runner_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001144 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001145 runner_values[i] <- runner_val
1146 }
1147
1148 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1149 min_idx <- which(numeric_row == min_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001150 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001151 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001152 if (has_urls) {
1153 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1154 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001155
1156 if (is.finite(max_val) && is.finite(min_val)) {
1157 max_deltas[i] <- max_val - min_val
1158 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001159 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001160 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001161
Marc Kupietzb2862d42025-10-18 10:17:49 +02001162 comparison[, value_cols] <- score_matrix
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001163 comparison[[winner_label_col]] <- winner_labels
1164 comparison[[winner_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001165 if (has_urls) {
1166 comparison[[winner_url_col]] <- winner_urls
1167 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001168 comparison[[runner_label_col]] <- runner_labels
1169 comparison[[runner_value_col]] <- runner_values
Marc Kupietzb2862d42025-10-18 10:17:49 +02001170 comparison[[loser_label_col]] <- loser_labels
1171 comparison[[loser_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001172 if (has_urls) {
1173 comparison[[loser_url_col]] <- loser_urls
1174 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001175 comparison[[max_delta_col]] <- max_deltas
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001176 }
1177
Marc Kupietz28a29842025-10-18 12:25:09 +02001178 for (col in score_cols) {
1179 rank_cols <- paste0("rank_", labels, "_", col)
1180 existing <- rank_cols %in% names(comparison)
1181 if (!any(existing)) {
1182 next
1183 }
1184 rank_cols <- rank_cols[existing]
1185 safe_labels <- labels[existing]
1186 rank_values <- comparison[, rank_cols, drop = FALSE]
1187
1188 winner_rank_label_col <- paste0("winner_rank_", col)
1189 winner_rank_value_col <- paste0("winner_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001190 winner_rank_url_col <- paste0("winner_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001191 runner_rank_label_col <- paste0("runner_up_rank_", col)
1192 runner_rank_value_col <- paste0("runner_up_rank_", col, "_value")
1193 loser_rank_label_col <- paste0("loser_rank_", col)
1194 loser_rank_value_col <- paste0("loser_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001195 loser_rank_url_col <- paste0("loser_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001196 max_delta_rank_col <- paste0("max_delta_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001197 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1198 has_urls <- all(url_cols %in% names(comparison))
1199 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz28a29842025-10-18 12:25:09 +02001200
1201 if (nrow(rank_values) == 0) {
1202 comparison[[winner_rank_label_col]] <- character(0)
1203 comparison[[winner_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001204 if (has_urls) {
1205 comparison[[winner_rank_url_col]] <- character(0)
1206 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001207 comparison[[runner_rank_label_col]] <- character(0)
1208 comparison[[runner_rank_value_col]] <- numeric(0)
1209 comparison[[loser_rank_label_col]] <- character(0)
1210 comparison[[loser_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001211 if (has_urls) {
1212 comparison[[loser_rank_url_col]] <- character(0)
1213 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001214 comparison[[max_delta_rank_col]] <- numeric(0)
1215 next
1216 }
1217
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001218 rank_matrix <- as.matrix(rank_values)
1219 storage.mode(rank_matrix) <- "numeric"
Marc Kupietz28a29842025-10-18 12:25:09 +02001220
1221 n_rows <- nrow(rank_matrix)
1222 winner_labels <- rep(NA_character_, n_rows)
1223 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001224 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001225 runner_labels <- rep(NA_character_, n_rows)
1226 runner_values <- rep(NA_real_, n_rows)
1227 loser_labels <- rep(NA_character_, n_rows)
1228 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001229 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001230 max_deltas <- rep(NA_real_, n_rows)
1231
1232 for (i in seq_len(n_rows)) {
1233 numeric_row <- as.numeric(rank_matrix[i, ])
1234 if (all(is.na(numeric_row))) {
1235 next
1236 }
1237
1238 if (length(rank_cols) > 0) {
1239 replacement_vec <- rank_replacements[rank_cols]
1240 replacement_vec[is.na(replacement_vec)] <- nrow(comparison) + 1
1241 missing_idx <- which(is.na(numeric_row))
1242 if (length(missing_idx) > 0) {
1243 numeric_row[missing_idx] <- replacement_vec[missing_idx]
1244 }
1245 }
1246
1247 valid_idx <- seq_along(numeric_row)
1248 valid_values <- numeric_row[valid_idx]
1249 min_val <- suppressWarnings(min(valid_values, na.rm = TRUE))
1250 min_positions <- valid_idx[which(valid_values == min_val)]
1251 winner_labels[i] <- collapse_label_values(min_positions, safe_labels)
1252 winner_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001253 if (has_urls) {
1254 winner_urls[i] <- collapse_url_values(min_positions, url_values[i, ])
1255 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001256
1257 ordered_vals <- sort(unique(valid_values), decreasing = FALSE)
1258 if (length(ordered_vals) >= 2) {
1259 runner_val <- ordered_vals[2]
1260 runner_positions <- valid_idx[which(valid_values == runner_val)]
1261 runner_labels[i] <- collapse_label_values(runner_positions, safe_labels)
1262 runner_values[i] <- runner_val
1263 }
1264
1265 max_val <- suppressWarnings(max(valid_values, na.rm = TRUE))
1266 max_positions <- valid_idx[which(valid_values == max_val)]
1267 loser_labels[i] <- collapse_label_values(max_positions, safe_labels)
1268 loser_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001269 if (has_urls) {
1270 loser_urls[i] <- collapse_url_values(max_positions, url_values[i, ])
1271 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001272
1273 if (is.finite(max_val) && is.finite(min_val)) {
1274 max_deltas[i] <- max_val - min_val
1275 }
1276 }
1277
1278 comparison[[winner_rank_label_col]] <- winner_labels
1279 comparison[[winner_rank_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001280 if (has_urls) {
1281 comparison[[winner_rank_url_col]] <- winner_urls
1282 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001283 comparison[[runner_rank_label_col]] <- runner_labels
1284 comparison[[runner_rank_value_col]] <- runner_values
1285 comparison[[loser_rank_label_col]] <- loser_labels
1286 comparison[[loser_rank_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001287 if (has_urls) {
1288 comparison[[loser_rank_url_col]] <- loser_urls
1289 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001290 comparison[[max_delta_rank_col]] <- max_deltas
1291 }
1292
Marc Kupietz130a2a22025-10-18 16:09:23 +02001293 for (col in score_cols) {
1294 pct_cols <- paste0("percentile_rank_", labels, "_", col)
1295 existing <- pct_cols %in% names(comparison)
1296 if (!any(existing)) {
1297 next
1298 }
1299 pct_cols <- pct_cols[existing]
1300 safe_labels <- labels[existing]
1301 pct_values <- comparison[, pct_cols, drop = FALSE]
1302
1303 winner_pct_label_col <- paste0("winner_percentile_rank_", col)
1304 winner_pct_value_col <- paste0("winner_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001305 winner_pct_url_col <- paste0("winner_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001306 runner_pct_label_col <- paste0("runner_up_percentile_rank_", col)
1307 runner_pct_value_col <- paste0("runner_up_percentile_rank_", col, "_value")
1308 loser_pct_label_col <- paste0("loser_percentile_rank_", col)
1309 loser_pct_value_col <- paste0("loser_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001310 loser_pct_url_col <- paste0("loser_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001311 max_delta_pct_col <- paste0("max_delta_percentile_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001312 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1313 has_urls <- all(url_cols %in% names(comparison))
1314 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz130a2a22025-10-18 16:09:23 +02001315
1316 if (nrow(pct_values) == 0) {
1317 comparison[[winner_pct_label_col]] <- character(0)
1318 comparison[[winner_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001319 if (has_urls) {
1320 comparison[[winner_pct_url_col]] <- character(0)
1321 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001322 comparison[[runner_pct_label_col]] <- character(0)
1323 comparison[[runner_pct_value_col]] <- numeric(0)
1324 comparison[[loser_pct_label_col]] <- character(0)
1325 comparison[[loser_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001326 if (has_urls) {
1327 comparison[[loser_pct_url_col]] <- character(0)
1328 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001329 comparison[[max_delta_pct_col]] <- numeric(0)
1330 next
1331 }
1332
1333 pct_matrix <- as.matrix(pct_values)
1334 storage.mode(pct_matrix) <- "numeric"
1335
1336 n_rows <- nrow(pct_matrix)
1337 winner_labels <- rep(NA_character_, n_rows)
1338 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001339 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001340 runner_labels <- rep(NA_character_, n_rows)
1341 runner_values <- rep(NA_real_, n_rows)
1342 loser_labels <- rep(NA_character_, n_rows)
1343 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001344 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001345 max_deltas <- rep(NA_real_, n_rows)
1346
1347 if (n_rows > 0) {
1348 for (i in seq_len(n_rows)) {
1349 numeric_row <- as.numeric(pct_matrix[i, ])
1350 if (all(is.na(numeric_row))) {
1351 next
1352 }
1353
1354 if (any(is.na(numeric_row))) {
1355 numeric_row[is.na(numeric_row)] <- 0
1356 }
1357 pct_matrix[i, ] <- numeric_row
1358
1359 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1360 max_idx <- which(numeric_row == max_val)
1361 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
1362 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001363 if (has_urls) {
1364 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1365 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001366
1367 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1368 if (length(unique_vals) >= 2) {
1369 runner_val <- unique_vals[2]
1370 runner_idx <- which(numeric_row == runner_val)
1371 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
1372 runner_values[i] <- runner_val
1373 }
1374
1375 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1376 min_idx <- which(numeric_row == min_val)
1377 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
1378 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001379 if (has_urls) {
1380 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1381 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001382
1383 if (is.finite(max_val) && is.finite(min_val)) {
1384 max_deltas[i] <- max_val - min_val
1385 }
1386 }
1387 }
1388
1389 comparison[, pct_cols] <- pct_matrix
1390 comparison[[winner_pct_label_col]] <- winner_labels
1391 comparison[[winner_pct_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001392 if (has_urls) {
1393 comparison[[winner_pct_url_col]] <- winner_urls
1394 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001395 comparison[[runner_pct_label_col]] <- runner_labels
1396 comparison[[runner_pct_value_col]] <- runner_values
1397 comparison[[loser_pct_label_col]] <- loser_labels
1398 comparison[[loser_pct_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001399 if (has_urls) {
1400 comparison[[loser_pct_url_col]] <- loser_urls
1401 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001402 comparison[[max_delta_pct_col]] <- max_deltas
1403 }
1404
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +02001405 for (flag_col in names(imputed_flags)) {
1406 comparison[[flag_col]] <- imputed_flags[[flag_col]]
1407 }
1408 if (length(imputed_flags) > 0) {
1409 comparison$n_imputed <- as.integer(Reduce(`+`, lapply(imputed_flags, as.integer)))
1410 } else {
1411 comparison$n_imputed <- rep(0L, nrow(comparison))
1412 }
1413 comparison$imputed <- comparison$n_imputed > 0L
1414
Marc Kupietz424cb782026-08-31 10:19:29 +02001415 n_imputed_rows <- sum(comparison$imputed)
1416 if (n_imputed_rows > 0) {
1417 log_info(verbose, sprintf(
1418 paste0(
1419 "Imputed scores for %d of %d node/collocate combinations (%d of %d label cells) ",
1420 "that are not attested in every virtual corpus. Their delta and winner/loser ",
1421 "columns reflect presence vs. absence rather than a measured contrast; see the ",
1422 "`imputed` column and `queryMissingScores`.\n"
1423 ),
1424 n_imputed_rows,
1425 nrow(comparison),
1426 sum(comparison$n_imputed),
1427 nrow(comparison) * length(labels)
1428 ))
1429 }
1430
Marc Kupietz09b1c082026-05-01 14:45:47 +02001431 collapse_consensus_url_columns <- function(url_cols) {
1432 if (length(url_cols) == 0) {
1433 return(rep(NA_character_, nrow(comparison)))
1434 }
1435 vapply(seq_len(nrow(comparison)), function(i) {
1436 urls <- unlist(comparison[i, url_cols, drop = FALSE], use.names = FALSE)
1437 urls <- as.character(urls)
1438 urls <- urls[!is.na(urls) & urls != ""]
1439 urls <- unique(urls)
1440 if (length(urls) == 1) {
1441 urls
1442 } else {
1443 NA_character_
1444 }
1445 }, character(1))
1446 }
1447
1448 winner_score_url_cols <- intersect(paste0("winner_", score_cols, "_webUIRequestUrl"), names(comparison))
1449 loser_score_url_cols <- intersect(paste0("loser_", score_cols, "_webUIRequestUrl"), names(comparison))
1450 if (length(winner_score_url_cols) > 0) {
1451 comparison$winner_webUIRequestUrl <- collapse_consensus_url_columns(winner_score_url_cols)
1452 }
1453 if (length(loser_score_url_cols) > 0) {
1454 comparison$loser_webUIRequestUrl <- collapse_consensus_url_columns(loser_score_url_cols)
1455 }
1456
1457 url_helper_cols <- intersect(paste0("webUIRequestUrl_", labels), names(comparison))
1458 if (length(url_helper_cols) > 0) {
1459 comparison <- dplyr::select(comparison, -dplyr::all_of(url_helper_cols))
1460 }
1461
Marc Kupietzc4540a22025-10-14 17:39:53 +02001462 dplyr::left_join(result, comparison, by = c("node", "collocate"))
1463}
1464
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001465#' @importFrom magrittr debug_pipe
Marc Kupietz2b17b212023-08-27 17:47:26 +02001466#' @importFrom stringr str_detect
1467#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1468#'
1469matches2FreqTable <- function(matches,
1470 index = 0,
1471 minOccur = 5,
1472 leftContextSize = 5,
1473 rightContextSize = 5,
1474 ignoreCollocateCase = FALSE,
1475 stopwords = c(),
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001476 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz2b17b212023-08-27 17:47:26 +02001477 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1478 verbose = TRUE) {
1479 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1480 frequency <- NULL
1481
1482 if (nrow(matches) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001483 dplyr::tibble(word = c(), frequency = c())
Marc Kupietz2b17b212023-08-27 17:47:26 +02001484 } else if (index == 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001485 if (!"tokens" %in% colnames(matches) || !is.list(matches$tokens)) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001486 log_info(verbose, "Outdated KorAP server: Falling back to client side tokenization.\n")
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001487 return(snippet2FreqTable(matches$snippet, minOccur, leftContextSize, rightContextSize,
1488 ignoreCollocateCase = ignoreCollocateCase,
1489 stopwords = stopwords, oldTable = oldTable, verbose = verbose
1490 ))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001491 }
1492 log_info(verbose, paste("Joining", nrow(matches), "kwics\n"))
Marc Kupietza25fbd92025-10-14 17:38:09 +02001493 for (i in seq_len(nrow(matches))) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001494 oldTable <- matches2FreqTable(
1495 matches,
1496 i,
1497 leftContextSize = leftContextSize,
1498 rightContextSize = rightContextSize,
1499 collocateFilterRegex = collocateFilterRegex,
1500 oldTable = oldTable,
1501 stopwords = stopwords
1502 )
1503 }
1504 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001505 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001506 group_by(word) |>
1507 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001508 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001509 arrange(desc(frequency))
1510 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001511 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietz2b17b212023-08-27 17:47:26 +02001512
1513 left <- tail(unlist(matches$tokens$left[index]), leftContextSize)
1514
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001515 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001516
1517 right <- head(unlist(matches$tokens$right[index]), rightContextSize)
1518
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001519 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001520
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001521 if (length(left) + length(right) == 0) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001522 oldTable
1523 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001524 table(c(left, right)) |>
1525 dplyr::as_tibble(.name_repair = "minimal") |>
1526 dplyr::rename(word = 1, frequency = 2) |>
1527 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001528 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001529 dplyr::bind_rows(oldTable)
1530 }
1531 }
1532}
1533
1534#' @importFrom magrittr debug_pipe
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001535#' @importFrom stringr str_match str_split str_detect
1536#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1537#'
1538snippet2FreqTable <- function(snippet,
1539 minOccur = 5,
1540 leftContextSize = 5,
1541 rightContextSize = 5,
1542 ignoreCollocateCase = FALSE,
1543 stopwords = c(),
1544 tokenizeRegex = "([! )(\uc2\uab,.:?\u201e\u201c\'\"]+|&quot;)",
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001545 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001546 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1547 verbose = TRUE) {
1548 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1549 frequency <- NULL
1550
1551 if (length(snippet) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001552 dplyr::tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001553 } else if (length(snippet) > 1) {
Marc Kupietza47d1502023-04-18 15:26:47 +02001554 log_info(verbose, paste("Joining", length(snippet), "kwics\n"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001555 for (s in snippet) {
1556 oldTable <- snippet2FreqTable(
1557 s,
1558 leftContextSize = leftContextSize,
1559 rightContextSize = rightContextSize,
Marc Kupietz47d0d2b2021-12-19 16:38:52 +01001560 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001561 oldTable = oldTable,
1562 stopwords = stopwords
1563 )
1564 }
Marc Kupietza47d1502023-04-18 15:26:47 +02001565 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001566 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001567 group_by(word) |>
1568 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001569 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001570 arrange(desc(frequency))
1571 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001572 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001573 match <-
1574 str_match(
1575 snippet,
1576 '<span class="context-left">(<span class="more"></span>)?(.*[^ ]) *</span><span class="match"><mark>.*</mark></span><span class="context-right"> *([^<]*)'
1577 )
1578
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001579 left <- if (leftContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001580 tail(unlist(str_split(match[1, 3], tokenizeRegex)), leftContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001581 } else {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001582 ""
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001583 }
1584 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001585
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001586 right <- if (rightContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001587 head(unlist(str_split(match[1, 4], tokenizeRegex)), rightContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001588 } else {
1589 ""
1590 }
1591 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001592
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001593 if (is.na(left[1]) || is.na(right[1]) || length(left) + length(right) == 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001594 oldTable
1595 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001596 table(c(left, right)) |>
1597 dplyr::as_tibble(.name_repair = "minimal") |>
1598 dplyr::rename(word = 1, frequency = 2) |>
1599 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001600 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001601 dplyr::bind_rows(oldTable)
1602 }
1603 }
1604}
1605
1606#' Preliminary synsemantic stopwords function
1607#'
1608#' @description
Marc Kupietz67edcb52021-09-20 21:54:24 +02001609#' `r lifecycle::badge("experimental")`
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001610#'
1611#' Preliminary synsemantic stopwords function to be used in collocation analysis.
1612#'
1613#' @details
1614#' Currently only suitable for German. See stopwords package for other languages.
1615#'
1616#' @param ... future arguments for language detection
1617#'
1618#' @family collocation analysis functions
1619#' @return Vector of synsemantic stopwords.
1620#' @export
1621synsemanticStopwords <- function(...) {
Marc Kupietzc79155b2025-10-19 13:42:55 +02001622 base <- c(
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001623 "der",
1624 "die",
1625 "und",
1626 "in",
1627 "den",
1628 "von",
1629 "mit",
1630 "das",
1631 "zu",
1632 "im",
1633 "ist",
1634 "auf",
1635 "sich",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001636 "des",
1637 "dem",
1638 "nicht",
1639 "ein",
1640 "eine",
1641 "es",
1642 "auch",
1643 "an",
1644 "als",
1645 "am",
1646 "aus",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001647 "bei",
1648 "er",
1649 "dass",
1650 "sie",
1651 "nach",
1652 "um",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001653 "zum",
1654 "noch",
1655 "war",
1656 "einen",
1657 "einer",
1658 "wie",
1659 "einem",
1660 "vor",
1661 "bis",
1662 "\u00fcber",
1663 "so",
1664 "aber",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001665 "diese",
Marc Kupietzc79155b2025-10-19 13:42:55 +02001666 "oder"
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001667 )
Marc Kupietzc79155b2025-10-19 13:42:55 +02001668
1669 lower <- unique(tolower(base))
1670 capitalized <- paste0(toupper(substr(lower, 1, 1)), substring(lower, 2))
1671
1672 unique(c(lower, capitalized))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001673}
1674
Marc Kupietz5a336b62021-11-27 17:51:35 +01001675
Marc Kupietz76b05592021-12-19 16:26:15 +01001676# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +01001677findExample <-
1678 function(kco,
1679 query,
1680 vc = "",
1681 matchOnly = TRUE) {
1682 out <- character(length = length(query))
1683
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001684 if (length(vc) < length(query)) {
Marc Kupietz5a336b62021-11-27 17:51:35 +01001685 vc <- rep(vc, length(query))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001686 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001687
1688 for (i in seq_along(query)) {
1689 q <- corpusQuery(kco, paste0("(", query[i], ")"), vc = vc[i], metadataOnly = FALSE)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001690 if (q@totalResults > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001691 q <- fetchNext(q, maxFetch = 50, randomizePageOrder = F)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001692 example <- as.character((q@collectedMatches)$snippet[1])
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001693 out[i] <- if (matchOnly) {
1694 gsub(".*<mark>(.+)</mark>.*", "\\1", example)
Marc Kupietz5a336b62021-11-27 17:51:35 +01001695 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001696 stringr::str_replace(example, "<[^>]*>", "")
Marc Kupietz5a336b62021-11-27 17:51:35 +01001697 }
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001698 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001699 out[i] <- ""
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001700 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001701 }
1702 out
1703 }
1704
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001705collocatesQuery <-
1706 function(kco,
1707 query,
1708 vc = "",
1709 minOccur = 5,
1710 leftContextSize = 5,
1711 rightContextSize = 5,
1712 searchHitsSampleLimit = 20000,
1713 ignoreCollocateCase = FALSE,
1714 stopwords = c(),
Marc Kupietzb2862d42025-10-18 10:17:49 +02001715 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001716 ...) {
1717 frequency <- NULL
1718 q <- corpusQuery(kco, query, vc, metadataOnly = F, ...)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001719 if (q@totalResults == 0) {
1720 tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001721 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001722 q <- fetchNext(q, maxFetch = searchHitsSampleLimit, randomizePageOrder = TRUE)
1723 matches2FreqTable(q@collectedMatches,
1724 0,
1725 minOccur = minOccur,
1726 leftContextSize = leftContextSize,
1727 rightContextSize = rightContextSize,
1728 ignoreCollocateCase = ignoreCollocateCase,
1729 stopwords = stopwords,
Marc Kupietzb2862d42025-10-18 10:17:49 +02001730 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001731 ...,
1732 verbose = kco@verbose
1733 ) |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001734 mutate(frequency = frequency * q@totalResults / min(q@totalResults, searchHitsSampleLimit)) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001735 filter(frequency >= minOccur)
1736 }
1737 }