blob: 34dc550de667033ea99b50147ffc19edd7aeca3c [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})
Marc Kupietzb6416be2026-09-03 14:37:37 +020098#' @param threshold minimum value of `thresholdScore` function call to apply collocation analysis recursively (only applied when \code{maxRecurse > 0}).
99#' Note that the default score, `logDice`, expresses how salient a pair is
100#' rather than how surprising, so that a frequent collocate can pass it while
101#' co-occurring less often than expected. Adding `dplyr::filter(O > E)`, or a
102#' minimum `pmi` or `ll`, removes those. See the "Salience versus surprise"
103#' section of \code{\link{association-score-functions}}.
Marc Kupietz7d400e02021-12-19 16:39:36 +0100104#' @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 +0100105#' @param collocateFilterRegex allow only collocates matching the regular expression
Marc Kupietzde679ea2025-10-19 13:14:51 +0200106#' @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 +0200107#' @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 +0200108#' @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 +0200109#' @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 +0200110#'
111#' The analysis parameters are stored alongside the result. If they differ from
112#' those of the current call, the cached result would not be the one that was
113#' asked for, so it is recomputed and the file overwritten, with a warning
114#' naming the parameters that differ. Pass a different \code{cacheAs} file name
115#' to keep an existing analysis. Cache files written by RKorAPClient 1.3.0 do
116#' not contain the parameters yet and are used as they are.
Marc Kupietz67edcb52021-09-20 21:54:24 +0200117#' @param ... more arguments will be passed to [collocationScoreQuery()]
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200118#' @inheritParams collocationScoreQuery,KorAPConnection-method
Marc Kupietz130a2a22025-10-18 16:09:23 +0200119#' @return
120#' A tibble where each row represents a candidate collocate for the requested node.
121#' Columns include (depending on the selected association measures):
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200122#'
Marc Kupietz130a2a22025-10-18 16:09:23 +0200123#' \itemize{
124#' \item \code{node}, \code{collocate}, \code{vc}, \code{label}: identifiers for the query node, collocate, virtual corpus, and optional label.
125#' \item Frequency and contingency information such as \code{frequency}, \code{O}, \code{O1}, \code{O2}, \code{E}, \code{leftContextSize}, \code{rightContextSize}, and \code{w}.
126#' \item Association measures (e.g. \code{logDice}, \code{ll}, \code{mi}, ...), one column per requested scorer.
127#' \item Per-labelled association scores produced by multi-VC comparisons using the pattern \code{<measure>_<label>}.
128#' \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>}.
129#' \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 +0200130#' \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 +0200131#' \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 +0200132#' \item Optional helper columns such as \code{query}, \code{example}, or \code{url} when example retrieval is requested.
133#' }
Marc Kupietz95253342026-08-31 10:18:43 +0200134#' @section Interpreting multi-VC comparisons:
135#'
Marc Kupietzba15cff2026-08-31 10:19:56 +0200136#' `r lifecycle::badge("experimental")`
137#'
138#' The comparison columns produced when `vc` holds more than one virtual corpus
139#' are experimental: their names and semantics may still change in a future
140#' release without a deprecation cycle. Code that has to keep working across
141#' versions should select the columns it needs explicitly.
142#'
143#' They are an exploration aid, not a significance test. When reading them, keep
144#' three properties in mind.
Marc Kupietz95253342026-08-31 10:18:43 +0200145#'
146#' \strong{Imputed scores describe presence/absence, not contrast.} A collocate
147#' that passes the `minOccur` and `topCollocatesLimit` thresholds in one virtual
148#' corpus but not in another has no observed score for the latter. Such cells are
149#' imputed from a floor derived from the pooled result set (see
150#' `missingScoreQuantile`), so the corresponding `delta_*` and `max_delta_*`
151#' values measure the distance to that floor rather than an attested difference.
152#' The `imputed`, `n_imputed` and `imputed_<label>` columns mark these rows;
153#' `dplyr::filter(!imputed)` restricts the result to collocates attested
154#' everywhere, and `queryMissingScores = TRUE` replaces most imputed cells with
155#' scores actually retrieved from the backend.
156#'
157#' \strong{Imputed values are relative to one analysis.} The floor is computed
158#' from the scores present in the result at hand. Analysing a node on its own and
159#' analysing it together with other nodes therefore yield different imputed
160#' values, and deltas involving imputed cells are not comparable across separate
161#' calls. Deltas between observed scores are unaffected.
162#'
163#' \strong{Winners carry no uncertainty.} Unlike [ci()], which attaches
164#' confidence intervals to relative frequencies, the `winner_*` / `loser_*`
165#' columns simply order point estimates. A collocate wins by a hair on six
166#' occurrences exactly as decisively as one that wins by a wide margin on
167#' thousands. Consult the observed frequencies (`O`, `O1`, `O2`) and the
168#' `webUIRequestUrl` concordance links before drawing conclusions from a
169#' small difference.
170#'
171#' Note also that `rank_<label>_<measure>` and
172#' `percentile_rank_<label>_<measure>` are computed within each label, over that
173#' label's own candidate set. Candidate sets usually differ in size between
174#' virtual corpora, so rank-based deltas compare positions in populations of
175#' different sizes.
Marc Kupietzc4540a22025-10-14 17:39:53 +0200176#' @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 +0200177#' @importFrom purrr pmap
Marc Kupietzc4540a22025-10-14 17:39:53 +0200178#' @importFrom tidyr expand_grid pivot_wider
179#' @importFrom rlang sym
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200180#'
181#' @examples
Marc Kupietz6ae76052021-09-21 10:34:00 +0200182#' \dontrun{
183#'
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200184#' # Find top collocates of "Packung" inside and outside the sports domain.
185#' KorAPConnection(verbose = TRUE) |>
186#' collocationAnalysis("Packung",
187#' vc = c("textClass=sport", "textClass!=sport"),
188#' leftContextSize = 1, rightContextSize = 1, topCollocatesLimit = 20
189#' ) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200190#' dplyr::filter(logDice >= 5)
191#' }
192#'
Marc Kupietz6ae76052021-09-21 10:34:00 +0200193#' \dontrun{
194#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200195#' # Identify the most prominent light verb construction with "in ... setzen".
196#' # Note that, currently, the use of focus function disallows exactFrequencies.
Marc Kupietz4cd066d2025-02-28 15:48:23 +0100197#' KorAPConnection(verbose = TRUE) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200198#' collocationAnalysis("focus(in [tt/p=NN] {[tt/l=setzen]})",
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200199#' leftContextSize = 1, rightContextSize = 0, exactFrequencies = FALSE, topCollocatesLimit = 20
200#' )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200201#' }
202#'
203#' @export
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200204setMethod(
205 "collocationAnalysis", "KorAPConnection",
206 function(kco,
207 node,
208 vc = "",
209 lemmatizeNodeQuery = FALSE,
210 minOccur = 5,
211 leftContextSize = 5,
212 rightContextSize = 5,
213 topCollocatesLimit = 200,
214 searchHitsSampleLimit = 20000,
215 ignoreCollocateCase = FALSE,
216 withinSpan = ifelse(exactFrequencies, "base/s=s", ""),
217 exactFrequencies = TRUE,
218 stopwords = append(RKorAPClient::synsemanticStopwords(), node),
219 seed = 7,
220 expand = length(vc) != length(node),
221 maxRecurse = 0,
222 addExamples = FALSE,
223 thresholdScore = "logDice",
224 threshold = 2.0,
225 localStopwords = c(),
226 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzde679ea2025-10-19 13:14:51 +0200227 queryMissingScores = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200228 missingScoreQuantile = 0.05,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200229 vcLabel = NA_character_,
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200230 cacheAs = NULL,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200231 ...) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200232 word <- frequency <- O <- NULL
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200233
Marc Kupietz37f96072026-09-03 07:18:11 +0200234 cacheParameters <- NULL
235 if (!is.null(cacheAs)) {
236 if (!grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
237 cacheAs <- paste0(cacheAs, ".rds")
238 }
239 cacheParameters <- collocationCacheParameters(environment(), list(...), kco)
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200240 }
241
242 if (!is.null(cacheAs) && file.exists(cacheAs)) {
Marc Kupietz37f96072026-09-03 07:18:11 +0200243 cached <- readRDS(cacheAs)
244 storedParameters <- attr(cached, collocationCacheAttribute)
245 attr(cached, collocationCacheAttribute) <- NULL
246
247 if (is.null(storedParameters)) {
248 # written before parameter checking existed, so there is nothing to check
249 log_info(kco@verbose, sprintf(
250 "Loading collocation analysis from cache (written without parameters): %s\n", cacheAs
251 ))
252 return(cached)
253 }
254
255 differing <- differingCollocationCacheParameters(storedParameters, cacheParameters)
256 if (length(differing) == 0) {
257 log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
258 return(cached)
259 }
260
261 warning(
262 sprintf(
263 paste0(
264 "Cache file '%s' was created with different parameters (%s) and is recomputed and overwritten.\n",
265 "Pass a different cacheAs file name to keep the cached analysis."
266 ),
267 cacheAs, paste(differing, collapse = ", ")
268 ),
269 call. = FALSE
270 )
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200271 }
272
Marc Kupietzb2862d42025-10-18 10:17:49 +0200273 if (!exactFrequencies && (!is.na(withinSpan) && !is.null(withinSpan) && nzchar(withinSpan))) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200274 stop(sprintf("Not empty withinSpan (='%s') requires exactFrequencies=TRUE", withinSpan), call. = FALSE)
275 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200276
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200277 warnIfNotAuthorized(kco)
Marc Kupietz581a29b2021-09-04 20:51:04 +0200278
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200279 if (lemmatizeNodeQuery) {
280 node <- lemmatizeWordQuery(node)
281 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200282
Marc Kupietze34a8be2025-10-17 20:13:42 +0200283 vcNames <- names(vc)
Marc Kupietze34a8be2025-10-17 20:13:42 +0200284 if (is.null(vcNames)) {
285 vcNames <- rep(NA_character_, length(vc))
Marc Kupietze34a8be2025-10-17 20:13:42 +0200286 }
287
288 label_lookup <- NULL
Marc Kupietzb2862d42025-10-18 10:17:49 +0200289 if (!is.null(names(vc)) && length(vc) > 0) {
290 raw_names <- names(vc)
291 if (any(!is.na(raw_names) & raw_names != "")) {
292 label_lookup <- stats::setNames(raw_names, vc)
293 }
Marc Kupietze34a8be2025-10-17 20:13:42 +0200294 }
295
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200296 result <- if (length(node) > 1 || length(vc) > 1) {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200297 grid <- if (expand) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200298 tmp_grid <- tidyr::expand_grid(node = node, idx = seq_along(vc))
299 tmp_grid$vc <- vc[tmp_grid$idx]
300 tmp_grid$vcLabel <- vcNames[tmp_grid$idx]
301 tmp_grid[, c("node", "vc", "vcLabel"), drop = FALSE]
Marc Kupietze34a8be2025-10-17 20:13:42 +0200302 } else {
303 tibble(node = node, vc = vc, vcLabel = vcNames)
304 }
305
306 multi_result <- purrr::pmap(grid, function(node, vc, vcLabel, ...) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200307 collocationAnalysis(kco,
308 node = node,
309 vc = vc,
310 minOccur = minOccur,
311 leftContextSize = leftContextSize,
312 rightContextSize = rightContextSize,
313 topCollocatesLimit = topCollocatesLimit,
314 searchHitsSampleLimit = searchHitsSampleLimit,
315 ignoreCollocateCase = ignoreCollocateCase,
316 withinSpan = withinSpan,
317 exactFrequencies = exactFrequencies,
318 stopwords = stopwords,
319 addExamples = TRUE,
320 localStopwords = localStopwords,
321 seed = seed,
322 expand = expand,
Marc Kupietz9894a372025-10-18 14:51:29 +0200323 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200324 queryMissingScores = queryMissingScores,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200325 collocateFilterRegex = collocateFilterRegex,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200326 vcLabel = vcLabel,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200327 ...
328 )
329 }) |>
Marc Kupietze31322e2025-10-17 18:55:36 +0200330 bind_rows()
331
332 if (!"vc" %in% names(multi_result) || nrow(multi_result) == 0) {
333 multi_result
334 } else {
Marc Kupietzde679ea2025-10-19 13:14:51 +0200335 if (queryMissingScores) {
336 multi_result <- backfill_missing_scores(
337 multi_result,
338 grid = grid,
339 kco = kco,
340 ignoreCollocateCase = ignoreCollocateCase,
341 ...
342 )
343 }
344
Marc Kupietze34a8be2025-10-17 20:13:42 +0200345 if (!"label" %in% names(multi_result)) {
346 multi_result$label <- NA_character_
347 }
348
349 if (!is.null(label_lookup)) {
350 override <- unname(label_lookup[multi_result$vc])
351 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
352 if (any(missing_idx)) {
353 multi_result$label[missing_idx] <- override[missing_idx]
354 }
355 }
356
357 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
358 if (any(missing_idx)) {
359 multi_result$label[missing_idx] <- queryStringToLabel(multi_result$vc[missing_idx])
360 }
361
Marc Kupietze31322e2025-10-17 18:55:36 +0200362 multi_result |>
Marc Kupietz9894a372025-10-18 14:51:29 +0200363 add_multi_vc_comparisons(
Marc Kupietz424cb782026-08-31 10:19:29 +0200364 missingScoreQuantile = missingScoreQuantile,
365 verbose = kco@verbose
Marc Kupietz9894a372025-10-18 14:51:29 +0200366 )
Marc Kupietze31322e2025-10-17 18:55:36 +0200367 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200368 } else {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200369 if ((is.na(vcLabel) || vcLabel == "") && length(vcNames) >= 1) {
370 vcLabel <- vcNames[1]
371 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200372
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200373 set.seed(seed)
374 candidates <- collocatesQuery(
375 kco,
376 node,
377 vc = vc,
378 minOccur = minOccur,
379 leftContextSize = leftContextSize,
380 rightContextSize = rightContextSize,
381 searchHitsSampleLimit = searchHitsSampleLimit,
382 ignoreCollocateCase = ignoreCollocateCase,
383 stopwords = append(stopwords, localStopwords),
Marc Kupietzb2862d42025-10-18 10:17:49 +0200384 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200385 ...
386 )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200387
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200388 if (nrow(candidates) > 0) {
389 candidates <- candidates |>
390 filter(frequency >= minOccur) |>
391 slice_head(n = topCollocatesLimit)
392 collocationScoreQuery(
393 kco,
394 node = node,
395 collocate = candidates$word,
396 vc = vc,
397 leftContextSize = leftContextSize,
398 rightContextSize = rightContextSize,
399 observed = if (exactFrequencies) NA else candidates$frequency,
400 ignoreCollocateCase = ignoreCollocateCase,
401 withinSpan = withinSpan,
402 ...
403 ) |>
404 filter(O >= minOccur) |>
405 dplyr::arrange(dplyr::desc(logDice))
406 } else {
407 tibble()
408 }
409 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200410
411 if (!is.na(vcLabel) && vcLabel != "" && "label" %in% names(result)) {
412 result$label <- rep(vcLabel, nrow(result))
413 }
414
415 threshold_col <- thresholdScore
416 if (maxRecurse > 0 && nrow(result) > 0 && threshold_col %in% names(result)) {
417 threshold_values <- result[[threshold_col]]
418 eligible_idx <- which(!is.na(threshold_values) & threshold_values >= threshold)
419 if (length(eligible_idx) > 0) {
420 recurseWith <- result[eligible_idx, , drop = FALSE]
421 result <- collocationAnalysis(
422 kco,
423 node = paste0("(", buildCollocationQuery(
424 removeWithinSpan(recurseWith$node, withinSpan),
425 recurseWith$collocate,
426 leftContextSize = leftContextSize,
427 rightContextSize = rightContextSize,
428 withinSpan = ""
429 ), ")"),
430 vc = vc,
431 minOccur = minOccur,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200432 leftContextSize = leftContextSize,
433 rightContextSize = rightContextSize,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200434 withinSpan = withinSpan,
435 maxRecurse = maxRecurse - 1,
436 stopwords = stopwords,
437 localStopwords = recurseWith$collocate,
438 exactFrequencies = exactFrequencies,
439 searchHitsSampleLimit = searchHitsSampleLimit,
440 topCollocatesLimit = topCollocatesLimit,
441 addExamples = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200442 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200443 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200444 queryMissingScores = queryMissingScores,
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200445 thresholdScore = thresholdScore,
446 threshold = threshold,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200447 vcLabel = vcLabel
448 ) |>
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200449 bind_rows(result)
450
451 if (threshold_col %in% names(result)) {
452 threshold_values <- result[[threshold_col]]
453 keep_idx <- is.na(threshold_values) | threshold_values >= threshold
454 result <- result[keep_idx, , drop = FALSE]
455 }
456
457 result <- result |>
Marc Kupietzb2862d42025-10-18 10:17:49 +0200458 filter(O >= minOccur) |>
459 dplyr::arrange(dplyr::desc(logDice))
460 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200461 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200462
463 if (addExamples && nrow(result) > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200464 result$query <- buildCollocationQuery(
465 result$node,
466 result$collocate,
467 leftContextSize = leftContextSize,
468 rightContextSize = rightContextSize,
469 withinSpan = withinSpan
470 )
471 result$example <- findExample(
472 kco,
473 query = result$query,
474 vc = result$vc
475 )
476 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200477
Marc Kupietz0a292632025-10-19 14:04:36 +0200478 if (!is.null(withinSpan) && !is.na(withinSpan) && nzchar(withinSpan) &&
479 nrow(result) > 0 &&
480 "webUIRequestUrl" %in% names(result) &&
481 "query" %in% names(result)) {
482 candidate_rows <- which(!is.na(result$node) &
483 !grepl("focus\\(", result$node, perl = TRUE) &
484 !is.na(result$query) & nzchar(result$query))
485
486 if (length(candidate_rows) > 0) {
487 focused_queries <- vapply(
488 result$query[candidate_rows],
489 inject_focus_into_query,
490 character(1)
491 )
492
493 changed <- focused_queries != result$query[candidate_rows]
494 if (any(changed)) {
495 indices <- candidate_rows[changed]
496 vc_values <- as.character(result$vc)
497 vc_values[is.na(vc_values)] <- ""
498
499 result$webUIRequestUrl[indices] <- mapply(
500 function(new_query, vc_value) {
501 buildWebUIRequestUrlFromString(
502 kco@KorAPUrl,
503 new_query,
504 vc = vc_value,
505 ql = "poliqarp"
506 )
507 },
508 focused_queries[changed],
509 vc_values[indices],
510 USE.NAMES = FALSE
511 )
512 }
513 }
514 }
515
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200516 if (!is.null(cacheAs)) {
517 log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", cacheAs))
Marc Kupietz37f96072026-09-03 07:18:11 +0200518 # only the stored copy carries the parameters, so that the returned value
519 # is the same whether it was cached or not
520 cachedResult <- result
521 attr(cachedResult, collocationCacheAttribute) <- cacheParameters
522 saveRDS(cachedResult, cacheAs)
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200523 }
524
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200525 result
526 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200527)
528
Marc Kupietz76b05592021-12-19 16:26:15 +0100529# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +0100530removeWithinSpan <- function(query, withinSpan) {
531 if (withinSpan == "") {
532 return(query)
533 }
534 needle <- sprintf("^\\(contains\\(<%s>, ?(.*)\\){2}$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200535 res <- gsub(needle, "\\1", query)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100536 needle <- sprintf("^contains\\(<%s>, ?(.*)\\)$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200537 res <- gsub(needle, "\\1", res)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100538 return(res)
539}
540
Marc Kupietzde679ea2025-10-19 13:14:51 +0200541backfill_missing_scores <- function(result,
542 grid,
543 kco,
544 ignoreCollocateCase,
545 ...) {
546 if (!"vc" %in% names(result) || !"node" %in% names(result) || !"collocate" %in% names(result)) {
547 return(result)
548 }
549
550 if (nrow(result) == 0) {
551 return(result)
552 }
553
Marc Kupietz9c53e412026-06-21 12:13:44 +0200554 distinct_pairs <- dplyr::distinct(
555 result,
556 .data$node,
557 .data$collocate
558 )
Marc Kupietzde679ea2025-10-19 13:14:51 +0200559 if (nrow(distinct_pairs) == 0) {
560 return(result)
561 }
562
563 collocates_by_node <- split(as.character(distinct_pairs$collocate), distinct_pairs$node)
564 if (length(collocates_by_node) == 0) {
565 return(result)
566 }
567
568 required_combinations <- unique(as.data.frame(grid[, c("node", "vc", "vcLabel")], drop = FALSE))
569 for (i in seq_len(nrow(required_combinations))) {
570 node_value <- required_combinations$node[i]
571 vc_value <- required_combinations$vc[i]
572
573 collocate_pool <- collocates_by_node[[node_value]]
574 if (is.null(collocate_pool) || length(collocate_pool) == 0) {
575 next
576 }
577
578 existing_idx <- result$node == node_value & result$vc == vc_value
579 existing_collocates <- unique(as.character(result$collocate[existing_idx]))
580 missing_collocates <- setdiff(unique(collocate_pool), existing_collocates)
581 missing_collocates <- missing_collocates[!is.na(missing_collocates) & nzchar(missing_collocates)]
582
583 if (length(missing_collocates) == 0) {
584 next
585 }
586
587 context_rows <- result[result$node == node_value & result$vc == vc_value, , drop = FALSE]
588 if (nrow(context_rows) == 0) {
589 context_rows <- result[result$node == node_value, , drop = FALSE]
590 }
591
592 left_size <- context_rows$leftContextSize[!is.na(context_rows$leftContextSize)][1]
593 if (is.na(left_size) || length(left_size) == 0) {
594 left_size <- result$leftContextSize[!is.na(result$leftContextSize)][1]
595 }
596 if (is.na(left_size) || length(left_size) == 0) {
597 left_size <- 5
598 }
599
600 right_size <- context_rows$rightContextSize[!is.na(context_rows$rightContextSize)][1]
601 if (is.na(right_size) || length(right_size) == 0) {
602 right_size <- result$rightContextSize[!is.na(result$rightContextSize)][1]
603 }
604 if (is.na(right_size) || length(right_size) == 0) {
605 right_size <- 5
606 }
607
608 within_span_value <- ""
609 if ("query" %in% names(context_rows)) {
610 query_candidate <- context_rows$query[!is.na(context_rows$query) & nzchar(context_rows$query)][1]
611 if (!is.na(query_candidate) && nzchar(query_candidate)) {
612 match_one <- regexec("^\\(*contains\\(<([^>]+)>,", query_candidate)
613 matches <- regmatches(query_candidate, match_one)
614 if (length(matches) >= 1 && length(matches[[1]]) >= 2) {
615 within_span_value <- matches[[1]][2]
616 }
617 }
618 }
619
620 new_rows <- collocationScoreQuery(
621 kco,
622 node = node_value,
623 collocate = missing_collocates,
624 vc = vc_value,
625 leftContextSize = left_size,
626 rightContextSize = right_size,
627 ignoreCollocateCase = ignoreCollocateCase,
628 withinSpan = within_span_value,
629 ...
630 )
631
632 if (nrow(new_rows) == 0) {
633 next
634 }
635
636 if (!is.null(required_combinations$vcLabel[i]) && !is.na(required_combinations$vcLabel[i]) && required_combinations$vcLabel[i] != "" && "label" %in% names(new_rows)) {
637 new_rows$label <- required_combinations$vcLabel[i]
638 }
639
640 result <- dplyr::bind_rows(result, new_rows)
641 }
642
643 result
644}
645
Marc Kupietz0a292632025-10-19 14:04:36 +0200646inject_focus_into_query <- function(query) {
647 if (is.null(query) || is.na(query)) {
648 return(query)
649 }
650
651 trimmed <- trimws(query)
652 if (!nzchar(trimmed)) {
653 return(query)
654 }
655
656 if (!grepl("^contains\\(<[^>]+>", trimmed, perl = TRUE)) {
657 return(query)
658 }
659
660 if (grepl("focus\\(", trimmed, perl = TRUE)) {
661 return(query)
662 }
663
664 pattern <- "^contains\\(<([^>]+)>\\s*,\\s*\\((.*)\\)\\)\\s*$"
665 matches <- regexec(pattern, trimmed, perl = TRUE)
666 components <- regmatches(trimmed, matches)
667 if (length(components) == 0 || length(components[[1]]) < 3) {
668 return(query)
669 }
670
671 span <- components[[1]][2]
672 inner <- components[[1]][3]
673 parts <- strsplit(inner, "\\|", perl = TRUE)[[1]]
674 parts <- trimws(parts)
675 parts <- parts[nzchar(parts)]
676
677 if (length(parts) == 0) {
678 return(query)
679 }
680
681 focused <- paste0("focus({", parts, "})")
682 combined <- paste(focused, collapse = " | ")
683
684 sprintf("contains(<%s>, (%s))", span, combined)
685}
686
Marc Kupietz424cb782026-08-31 10:19:29 +0200687add_multi_vc_comparisons <- function(result, missingScoreQuantile = 0.05, verbose = FALSE) {
Marc Kupietz09b1c082026-05-01 14:45:47 +0200688 label <- node <- collocate <- vc <- webUIRequestUrl <- NULL
Marc Kupietzc4540a22025-10-14 17:39:53 +0200689
690 if (!"label" %in% names(result) || dplyr::n_distinct(result$label) < 2) {
691 return(result)
692 }
693
694 numeric_cols <- names(result)[vapply(result, is.numeric, logical(1))]
695 non_score_cols <- c("N", "O", "O1", "O2", "E", "w", "leftContextSize", "rightContextSize", "frequency")
696 score_cols <- setdiff(numeric_cols, non_score_cols)
697
698 if (length(score_cols) == 0) {
699 return(result)
700 }
701
Marc Kupietz9894a372025-10-18 14:51:29 +0200702 compute_score_floor <- function(values) {
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200703 # Estimate a conservative floor so missing scores can be imputed without favoring any label
Marc Kupietz9894a372025-10-18 14:51:29 +0200704 finite_values <- values[is.finite(values)]
705 if (length(finite_values) == 0) {
706 return(0)
707 }
708
709 prob <- min(max(missingScoreQuantile, 0), 0.5)
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200710 # Use a lower quantile as the anchor to stay near the weakest attested scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200711 q_val <- suppressWarnings(stats::quantile(finite_values,
712 probs = prob,
713 names = FALSE,
714 type = 7
715 ))
716
717 if (!is.finite(q_val)) {
718 q_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
719 }
720
721 min_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
722 if (!is.finite(min_val)) {
723 min_val <- 0
724 }
725
726 spread_candidates <- c(
727 suppressWarnings(stats::IQR(finite_values, na.rm = TRUE, type = 7)),
728 stats::sd(finite_values, na.rm = TRUE),
729 abs(q_val) * 0.1,
730 abs(min_val - q_val)
731 )
732 spread_candidates <- spread_candidates[is.finite(spread_candidates)]
733
734 spread <- 0
735 if (length(spread_candidates) > 0) {
736 spread <- max(spread_candidates)
737 }
738 if (!is.finite(spread) || spread == 0) {
739 spread <- max(abs(q_val), abs(min_val), 1e-06)
740 }
741
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200742 # Step away from the anchor by a robust spread estimate to avoid ties with real scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200743 candidate <- q_val - spread
744 if (!is.finite(candidate)) {
745 candidate <- min_val
746 }
747
748 floor_value <- suppressWarnings(min(c(candidate, min_val), na.rm = TRUE))
749 if (!is.finite(floor_value)) {
750 floor_value <- min_val
751 }
752 if (!is.finite(floor_value)) {
753 floor_value <- 0
754 }
755
756 floor_value
757 }
758
759 score_replacements <- stats::setNames(
760 vapply(score_cols, function(col) {
761 compute_score_floor(result[[col]])
762 }, numeric(1)),
763 score_cols
764 )
765
Marc Kupietz7b7a73b2026-08-31 10:31:27 +0200766 # The pivots below keep only the first row per node/collocate/label. Duplicates do occur
767 # legitimately (e.g. the same collocate found at several context positions), but silently
768 # discarding all but one of them would misrepresent the comparison, so say so.
769 comparison_keys <- paste(result$node, result$collocate, result$label, sep = "\r")
770 duplicate_keys <- unique(comparison_keys[duplicated(comparison_keys)])
771 if (length(duplicate_keys) > 0) {
772 warning(
773 sprintf(
774 paste0(
775 "%d node/collocate/label combination(s) occur more than once; only the first row ",
776 "of each is used for the multi-VC comparison columns. Consider ",
777 "mergeDuplicateCollocates() to combine context positions before comparing."
778 ),
779 length(duplicate_keys)
780 ),
781 call. = FALSE
782 )
783 }
784
Marc Kupietzc4540a22025-10-14 17:39:53 +0200785 comparison <- result |>
Marc Kupietz28a29842025-10-18 12:25:09 +0200786 dplyr::select(node, collocate, label, dplyr::all_of(score_cols)) |>
787 tidyr::pivot_wider(
Marc Kupietzc4540a22025-10-14 17:39:53 +0200788 names_from = label,
Marc Kupietz28a29842025-10-18 12:25:09 +0200789 values_from = dplyr::all_of(score_cols),
Marc Kupietzc4540a22025-10-14 17:39:53 +0200790 names_glue = "{.value}_{make.names(label)}",
791 values_fn = dplyr::first
792 )
793
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200794 raw_labels <- unique(result$label)
795 labels <- make.names(raw_labels)
796 label_map <- stats::setNames(raw_labels, labels)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200797 vc_map <- result |>
798 dplyr::select(label, vc) |>
799 dplyr::filter(!is.na(label), label != "") |>
800 dplyr::distinct(label, .keep_all = TRUE)
801 vc_map <- stats::setNames(vc_map$vc, make.names(vc_map$label))
802
803 replace_web_ui_cq <- function(url, vc_value) {
804 if (length(url) == 0 || is.na(url) || url == "") {
805 return(NA_character_)
806 }
807 if (length(vc_value) == 0 || is.na(vc_value)) {
808 vc_value <- ""
809 }
810 encoded_vc <- urltools::url_encode(enc2utf8(as.character(vc_value)))
811 if (grepl("([?&]cq=)[^&]*", url, perl = TRUE)) {
812 return(sub("([?&]cq=)[^&]*", paste0("\\1", encoded_vc), url, perl = TRUE))
813 }
814 if (encoded_vc == "") {
815 return(url)
816 }
817 paste0(url, ifelse(grepl("\\?", url), "&", "?"), "cq=", encoded_vc)
818 }
819
820 if ("webUIRequestUrl" %in% names(result)) {
821 url_data <- result |>
822 dplyr::select(node, collocate, label, webUIRequestUrl) |>
823 tidyr::pivot_wider(
824 names_from = label,
825 values_from = webUIRequestUrl,
826 names_glue = "webUIRequestUrl_{make.names(label)}",
827 values_fn = dplyr::first
828 )
829
830 comparison <- dplyr::left_join(comparison, url_data, by = c("node", "collocate"))
831
832 url_cols <- paste0("webUIRequestUrl_", labels)
833 present_url_cols <- intersect(url_cols, names(comparison))
834 fallback_urls <- vapply(seq_len(nrow(comparison)), function(i) {
835 urls <- unlist(comparison[i, present_url_cols, drop = FALSE], use.names = FALSE)
836 urls <- as.character(urls)
837 urls <- urls[!is.na(urls) & urls != ""]
838 if (length(urls) == 0) {
839 NA_character_
840 } else {
841 urls[1]
842 }
843 }, character(1))
844
845 for (safe_label in labels) {
846 url_col <- paste0("webUIRequestUrl_", safe_label)
847 if (!url_col %in% names(comparison)) {
848 comparison[[url_col]] <- NA_character_
849 }
850 missing_urls <- is.na(comparison[[url_col]]) | comparison[[url_col]] == ""
851 if (any(missing_urls)) {
852 comparison[[url_col]][missing_urls] <- vapply(
853 fallback_urls[missing_urls],
854 replace_web_ui_cq,
855 character(1),
856 vc_value = vc_map[[safe_label]]
857 )
858 }
859 }
860 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200861
Marc Kupietz28a29842025-10-18 12:25:09 +0200862 rank_data <- result |>
863 dplyr::distinct(node, collocate)
864
865 for (i in seq_along(raw_labels)) {
866 raw_lab <- raw_labels[i]
867 safe_lab <- labels[i]
868 label_df <- result[result$label == raw_lab, c("node", "collocate", score_cols), drop = FALSE]
869 if (nrow(label_df) == 0) {
870 next
871 }
872 label_df <- dplyr::distinct(label_df)
873 rank_tbl <- label_df[, c("node", "collocate"), drop = FALSE]
874 for (col in score_cols) {
875 rank_col_name <- paste0("rank_", safe_lab, "_", col)
Marc Kupietz130a2a22025-10-18 16:09:23 +0200876 percentile_col_name <- paste0("percentile_rank_", safe_lab, "_", col)
Marc Kupietz28a29842025-10-18 12:25:09 +0200877 values <- label_df[[col]]
878 ranks <- rep(NA_real_, length(values))
Marc Kupietz130a2a22025-10-18 16:09:23 +0200879 percentiles <- rep(NA_real_, length(values))
Marc Kupietz28a29842025-10-18 12:25:09 +0200880 valid_idx <- which(!is.na(values))
881 if (length(valid_idx) > 0) {
882 ranks[valid_idx] <- rank(-values[valid_idx], ties.method = "first")
Marc Kupietz130a2a22025-10-18 16:09:23 +0200883 total <- length(valid_idx)
884 percentiles[valid_idx] <- 1 - (ranks[valid_idx] - 1) / total
Marc Kupietz28a29842025-10-18 12:25:09 +0200885 }
886 rank_tbl[[rank_col_name]] <- ranks
Marc Kupietz130a2a22025-10-18 16:09:23 +0200887 rank_tbl[[percentile_col_name]] <- percentiles
Marc Kupietz28a29842025-10-18 12:25:09 +0200888 }
889 rank_data <- dplyr::left_join(rank_data, rank_tbl, by = c("node", "collocate"))
890 }
891
892 comparison <- dplyr::left_join(comparison, rank_data, by = c("node", "collocate"))
893
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +0200894 # Record which label/measure cells are absent *before* any imputation happens below.
895 # Deltas computed from imputed cells reflect presence/absence of the collocate in a
896 # virtual corpus, not a measured contrast, so users need to be able to tell them apart.
897 imputed_flags <- lapply(labels, function(safe_label) {
898 label_score_cols <- intersect(paste0(score_cols, "_", safe_label), names(comparison))
899 if (length(label_score_cols) == 0) {
900 return(rep(FALSE, nrow(comparison)))
901 }
902 Reduce(`|`, lapply(label_score_cols, function(col) is.na(comparison[[col]])))
903 })
904 names(imputed_flags) <- paste0("imputed_", labels)
905
Marc Kupietz28a29842025-10-18 12:25:09 +0200906 rank_replacements <- numeric(0)
907 rank_column_names <- grep("^rank_", names(comparison), value = TRUE)
908 if (length(rank_column_names) > 0) {
909 rank_replacements <- stats::setNames(
910 vapply(rank_column_names, function(col) {
911 col_values <- comparison[[col]]
912 valid_values <- col_values[!is.na(col_values)]
913 if (length(valid_values) == 0) {
914 nrow(comparison) + 1
915 } else {
916 suppressWarnings(max(valid_values, na.rm = TRUE)) + 1
917 }
918 }, numeric(1)),
919 rank_column_names
920 )
921 }
922
Marc Kupietz130a2a22025-10-18 16:09:23 +0200923 percentile_replacements <- numeric(0)
924 percentile_column_names <- grep("^percentile_rank_", names(comparison), value = TRUE)
925 if (length(percentile_column_names) > 0) {
926 percentile_replacements <- stats::setNames(
927 rep(0, length(percentile_column_names)),
928 percentile_column_names
929 )
930 }
931
Marc Kupietz28a29842025-10-18 12:25:09 +0200932 collapse_label_values <- function(indices, safe_labels_vec) {
933 if (length(indices) == 0) {
934 return(NA_character_)
935 }
936 labs <- label_map[safe_labels_vec[indices]]
937 fallback <- safe_labels_vec[indices]
938 labs[is.na(labs) | labs == ""] <- fallback[is.na(labs) | labs == ""]
939 labs <- labs[!is.na(labs) & labs != ""]
940 if (length(labs) == 0) {
941 return(NA_character_)
942 }
943 paste(unique(labs), collapse = ", ")
944 }
945
Marc Kupietz09b1c082026-05-01 14:45:47 +0200946 collapse_url_values <- function(indices, url_values) {
947 if (length(indices) == 0 || is.null(url_values)) {
948 return(NA_character_)
949 }
950 urls <- as.character(url_values[indices])
951 urls <- urls[!is.na(urls) & urls != ""]
952 if (length(urls) == 0) {
953 return(NA_character_)
954 }
955 paste(unique(urls), collapse = ", ")
956 }
957
Marc Kupietzc4540a22025-10-14 17:39:53 +0200958 if (length(labels) == 2) {
Marc Kupietz9894a372025-10-18 14:51:29 +0200959 fill_scores <- function(x, y, measure_col) {
960 replacement <- score_replacements[[measure_col]]
961 fallback_min <- suppressWarnings(min(c(x, y), na.rm = TRUE))
962 if (!is.finite(fallback_min)) {
963 fallback_min <- 0
Marc Kupietzc4540a22025-10-14 17:39:53 +0200964 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200965 if (!is.null(replacement) && is.finite(replacement)) {
966 replacement <- min(replacement, fallback_min)
967 } else {
968 replacement <- fallback_min
969 }
970 if (!is.finite(replacement)) {
971 replacement <- 0
972 }
973 if (any(is.na(x))) {
974 x[is.na(x)] <- replacement
975 }
976 if (any(is.na(y))) {
977 y[is.na(y)] <- replacement
978 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200979 list(x = x, y = y)
980 }
981
Marc Kupietz130a2a22025-10-18 16:09:23 +0200982 fill_percentiles <- function(x, y, left_pct_col, right_pct_col) {
983 replacement_left <- percentile_replacements[[left_pct_col]]
984 if (is.null(replacement_left) || !is.finite(replacement_left)) {
985 replacement_left <- 0
986 }
987 replacement_right <- percentile_replacements[[right_pct_col]]
988 if (is.null(replacement_right) || !is.finite(replacement_right)) {
989 replacement_right <- 0
990 }
991 if (any(is.na(x))) {
992 x[is.na(x)] <- replacement_left
993 }
994 if (any(is.na(y))) {
995 y[is.na(y)] <- replacement_right
996 }
997 list(x = x, y = y)
998 }
999
Marc Kupietz28a29842025-10-18 12:25:09 +02001000 fill_ranks <- function(x, y, left_rank_col, right_rank_col) {
1001 fallback <- nrow(comparison) + 1
1002 replacement_left <- rank_replacements[[left_rank_col]]
1003 if (is.null(replacement_left) || !is.finite(replacement_left)) {
1004 replacement_left <- fallback
Marc Kupietzc4540a22025-10-14 17:39:53 +02001005 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001006 replacement_right <- rank_replacements[[right_rank_col]]
1007 if (is.null(replacement_right) || !is.finite(replacement_right)) {
1008 replacement_right <- fallback
1009 }
1010 if (any(is.na(x))) {
1011 x[is.na(x)] <- replacement_left
1012 }
1013 if (any(is.na(y))) {
1014 y[is.na(y)] <- replacement_right
1015 }
Marc Kupietzc4540a22025-10-14 17:39:53 +02001016 list(x = x, y = y)
1017 }
1018
1019 left_label <- labels[1]
1020 right_label <- labels[2]
1021
1022 for (col in score_cols) {
1023 left_col <- paste0(col, "_", left_label)
1024 right_col <- paste0(col, "_", right_label)
1025 if (!all(c(left_col, right_col) %in% names(comparison))) {
1026 next
1027 }
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001028 filled <- fill_scores(comparison[[left_col]], comparison[[right_col]], col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001029 comparison[[left_col]] <- filled$x
1030 comparison[[right_col]] <- filled$y
Marc Kupietzc4540a22025-10-14 17:39:53 +02001031 comparison[[paste0("delta_", col)]] <- filled$x - filled$y
Marc Kupietz28a29842025-10-18 12:25:09 +02001032 rank_left <- paste0("rank_", left_label, "_", col)
1033 rank_right <- paste0("rank_", right_label, "_", col)
1034 if (all(c(rank_left, rank_right) %in% names(comparison))) {
1035 filled_rank <- fill_ranks(
1036 comparison[[rank_left]],
1037 comparison[[rank_right]],
1038 rank_left,
1039 rank_right
1040 )
1041 comparison[[paste0("delta_rank_", col)]] <- filled_rank$x - filled_rank$y
1042 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001043 pct_left <- paste0("percentile_rank_", left_label, "_", col)
1044 pct_right <- paste0("percentile_rank_", right_label, "_", col)
1045 if (all(c(pct_left, pct_right) %in% names(comparison))) {
1046 filled_pct <- fill_percentiles(
1047 comparison[[pct_left]],
1048 comparison[[pct_right]],
1049 pct_left,
1050 pct_right
1051 )
1052 comparison[[paste0("delta_percentile_rank_", col)]] <- filled_pct$x - filled_pct$y
1053 }
Marc Kupietzc4540a22025-10-14 17:39:53 +02001054 }
1055 }
1056
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001057 for (col in score_cols) {
1058 value_cols <- paste0(col, "_", labels)
1059 existing <- value_cols %in% names(comparison)
1060 if (!any(existing)) {
1061 next
1062 }
1063 value_cols <- value_cols[existing]
1064 safe_labels <- labels[existing]
1065
1066 score_values <- comparison[, value_cols, drop = FALSE]
1067
1068 winner_label_col <- paste0("winner_", col)
1069 winner_value_col <- paste0("winner_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001070 winner_url_col <- paste0("winner_", col, "_webUIRequestUrl")
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001071 runner_label_col <- paste0("runner_up_", col)
1072 runner_value_col <- paste0("runner_up_", col, "_value")
Marc Kupietzb2862d42025-10-18 10:17:49 +02001073 loser_label_col <- paste0("loser_", col)
1074 loser_value_col <- paste0("loser_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001075 loser_url_col <- paste0("loser_", col, "_webUIRequestUrl")
Marc Kupietzb2862d42025-10-18 10:17:49 +02001076 max_delta_col <- paste0("max_delta_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001077 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1078 has_urls <- all(url_cols %in% names(comparison))
1079 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001080
1081 if (nrow(score_values) == 0) {
1082 comparison[[winner_label_col]] <- character(0)
1083 comparison[[winner_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001084 if (has_urls) {
1085 comparison[[winner_url_col]] <- character(0)
1086 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001087 comparison[[runner_label_col]] <- character(0)
1088 comparison[[runner_value_col]] <- numeric(0)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001089 comparison[[loser_label_col]] <- character(0)
1090 comparison[[loser_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001091 if (has_urls) {
1092 comparison[[loser_url_col]] <- character(0)
1093 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001094 comparison[[max_delta_col]] <- numeric(0)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001095 next
1096 }
1097
1098 score_matrix <- as.matrix(score_values)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001099 storage.mode(score_matrix) <- "numeric"
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001100
Marc Kupietzb2862d42025-10-18 10:17:49 +02001101 n_rows <- nrow(score_matrix)
1102 winner_labels <- rep(NA_character_, n_rows)
1103 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001104 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001105 runner_labels <- rep(NA_character_, n_rows)
1106 runner_values <- rep(NA_real_, n_rows)
1107 loser_labels <- rep(NA_character_, n_rows)
1108 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001109 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001110 max_deltas <- rep(NA_real_, n_rows)
1111
Marc Kupietzb2862d42025-10-18 10:17:49 +02001112 if (n_rows > 0) {
1113 for (i in seq_len(n_rows)) {
1114 numeric_row <- as.numeric(score_matrix[i, ])
1115 if (all(is.na(numeric_row))) {
1116 next
1117 }
1118
Marc Kupietz9894a372025-10-18 14:51:29 +02001119 replacement <- score_replacements[[col]]
1120 fallback_min <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1121 if (!is.finite(fallback_min)) {
1122 fallback_min <- 0
Marc Kupietzb2862d42025-10-18 10:17:49 +02001123 }
Marc Kupietz9894a372025-10-18 14:51:29 +02001124 if (!is.null(replacement) && is.finite(replacement)) {
1125 replacement <- min(replacement, fallback_min)
1126 } else {
1127 replacement <- fallback_min
1128 }
1129 if (!is.finite(replacement)) {
1130 replacement <- 0
1131 }
1132 if (any(is.na(numeric_row))) {
1133 numeric_row[is.na(numeric_row)] <- replacement
1134 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001135 score_matrix[i, ] <- numeric_row
1136
1137 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1138 max_idx <- which(numeric_row == max_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001139 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001140 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001141 if (has_urls) {
1142 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1143 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001144
1145 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1146 if (length(unique_vals) >= 2) {
1147 runner_val <- unique_vals[2]
1148 runner_idx <- which(numeric_row == runner_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001149 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001150 runner_values[i] <- runner_val
1151 }
1152
1153 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1154 min_idx <- which(numeric_row == min_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001155 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001156 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001157 if (has_urls) {
1158 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1159 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001160
1161 if (is.finite(max_val) && is.finite(min_val)) {
1162 max_deltas[i] <- max_val - min_val
1163 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001164 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001165 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001166
Marc Kupietzb2862d42025-10-18 10:17:49 +02001167 comparison[, value_cols] <- score_matrix
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001168 comparison[[winner_label_col]] <- winner_labels
1169 comparison[[winner_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001170 if (has_urls) {
1171 comparison[[winner_url_col]] <- winner_urls
1172 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001173 comparison[[runner_label_col]] <- runner_labels
1174 comparison[[runner_value_col]] <- runner_values
Marc Kupietzb2862d42025-10-18 10:17:49 +02001175 comparison[[loser_label_col]] <- loser_labels
1176 comparison[[loser_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001177 if (has_urls) {
1178 comparison[[loser_url_col]] <- loser_urls
1179 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001180 comparison[[max_delta_col]] <- max_deltas
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001181 }
1182
Marc Kupietz28a29842025-10-18 12:25:09 +02001183 for (col in score_cols) {
1184 rank_cols <- paste0("rank_", labels, "_", col)
1185 existing <- rank_cols %in% names(comparison)
1186 if (!any(existing)) {
1187 next
1188 }
1189 rank_cols <- rank_cols[existing]
1190 safe_labels <- labels[existing]
1191 rank_values <- comparison[, rank_cols, drop = FALSE]
1192
1193 winner_rank_label_col <- paste0("winner_rank_", col)
1194 winner_rank_value_col <- paste0("winner_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001195 winner_rank_url_col <- paste0("winner_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001196 runner_rank_label_col <- paste0("runner_up_rank_", col)
1197 runner_rank_value_col <- paste0("runner_up_rank_", col, "_value")
1198 loser_rank_label_col <- paste0("loser_rank_", col)
1199 loser_rank_value_col <- paste0("loser_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001200 loser_rank_url_col <- paste0("loser_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001201 max_delta_rank_col <- paste0("max_delta_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001202 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1203 has_urls <- all(url_cols %in% names(comparison))
1204 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz28a29842025-10-18 12:25:09 +02001205
1206 if (nrow(rank_values) == 0) {
1207 comparison[[winner_rank_label_col]] <- character(0)
1208 comparison[[winner_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001209 if (has_urls) {
1210 comparison[[winner_rank_url_col]] <- character(0)
1211 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001212 comparison[[runner_rank_label_col]] <- character(0)
1213 comparison[[runner_rank_value_col]] <- numeric(0)
1214 comparison[[loser_rank_label_col]] <- character(0)
1215 comparison[[loser_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001216 if (has_urls) {
1217 comparison[[loser_rank_url_col]] <- character(0)
1218 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001219 comparison[[max_delta_rank_col]] <- numeric(0)
1220 next
1221 }
1222
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001223 rank_matrix <- as.matrix(rank_values)
1224 storage.mode(rank_matrix) <- "numeric"
Marc Kupietz28a29842025-10-18 12:25:09 +02001225
1226 n_rows <- nrow(rank_matrix)
1227 winner_labels <- rep(NA_character_, n_rows)
1228 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001229 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001230 runner_labels <- rep(NA_character_, n_rows)
1231 runner_values <- rep(NA_real_, n_rows)
1232 loser_labels <- rep(NA_character_, n_rows)
1233 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001234 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001235 max_deltas <- rep(NA_real_, n_rows)
1236
1237 for (i in seq_len(n_rows)) {
1238 numeric_row <- as.numeric(rank_matrix[i, ])
1239 if (all(is.na(numeric_row))) {
1240 next
1241 }
1242
1243 if (length(rank_cols) > 0) {
1244 replacement_vec <- rank_replacements[rank_cols]
1245 replacement_vec[is.na(replacement_vec)] <- nrow(comparison) + 1
1246 missing_idx <- which(is.na(numeric_row))
1247 if (length(missing_idx) > 0) {
1248 numeric_row[missing_idx] <- replacement_vec[missing_idx]
1249 }
1250 }
1251
1252 valid_idx <- seq_along(numeric_row)
1253 valid_values <- numeric_row[valid_idx]
1254 min_val <- suppressWarnings(min(valid_values, na.rm = TRUE))
1255 min_positions <- valid_idx[which(valid_values == min_val)]
1256 winner_labels[i] <- collapse_label_values(min_positions, safe_labels)
1257 winner_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001258 if (has_urls) {
1259 winner_urls[i] <- collapse_url_values(min_positions, url_values[i, ])
1260 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001261
1262 ordered_vals <- sort(unique(valid_values), decreasing = FALSE)
1263 if (length(ordered_vals) >= 2) {
1264 runner_val <- ordered_vals[2]
1265 runner_positions <- valid_idx[which(valid_values == runner_val)]
1266 runner_labels[i] <- collapse_label_values(runner_positions, safe_labels)
1267 runner_values[i] <- runner_val
1268 }
1269
1270 max_val <- suppressWarnings(max(valid_values, na.rm = TRUE))
1271 max_positions <- valid_idx[which(valid_values == max_val)]
1272 loser_labels[i] <- collapse_label_values(max_positions, safe_labels)
1273 loser_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001274 if (has_urls) {
1275 loser_urls[i] <- collapse_url_values(max_positions, url_values[i, ])
1276 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001277
1278 if (is.finite(max_val) && is.finite(min_val)) {
1279 max_deltas[i] <- max_val - min_val
1280 }
1281 }
1282
1283 comparison[[winner_rank_label_col]] <- winner_labels
1284 comparison[[winner_rank_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001285 if (has_urls) {
1286 comparison[[winner_rank_url_col]] <- winner_urls
1287 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001288 comparison[[runner_rank_label_col]] <- runner_labels
1289 comparison[[runner_rank_value_col]] <- runner_values
1290 comparison[[loser_rank_label_col]] <- loser_labels
1291 comparison[[loser_rank_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001292 if (has_urls) {
1293 comparison[[loser_rank_url_col]] <- loser_urls
1294 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001295 comparison[[max_delta_rank_col]] <- max_deltas
1296 }
1297
Marc Kupietz130a2a22025-10-18 16:09:23 +02001298 for (col in score_cols) {
1299 pct_cols <- paste0("percentile_rank_", labels, "_", col)
1300 existing <- pct_cols %in% names(comparison)
1301 if (!any(existing)) {
1302 next
1303 }
1304 pct_cols <- pct_cols[existing]
1305 safe_labels <- labels[existing]
1306 pct_values <- comparison[, pct_cols, drop = FALSE]
1307
1308 winner_pct_label_col <- paste0("winner_percentile_rank_", col)
1309 winner_pct_value_col <- paste0("winner_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001310 winner_pct_url_col <- paste0("winner_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001311 runner_pct_label_col <- paste0("runner_up_percentile_rank_", col)
1312 runner_pct_value_col <- paste0("runner_up_percentile_rank_", col, "_value")
1313 loser_pct_label_col <- paste0("loser_percentile_rank_", col)
1314 loser_pct_value_col <- paste0("loser_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001315 loser_pct_url_col <- paste0("loser_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001316 max_delta_pct_col <- paste0("max_delta_percentile_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001317 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1318 has_urls <- all(url_cols %in% names(comparison))
1319 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz130a2a22025-10-18 16:09:23 +02001320
1321 if (nrow(pct_values) == 0) {
1322 comparison[[winner_pct_label_col]] <- character(0)
1323 comparison[[winner_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001324 if (has_urls) {
1325 comparison[[winner_pct_url_col]] <- character(0)
1326 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001327 comparison[[runner_pct_label_col]] <- character(0)
1328 comparison[[runner_pct_value_col]] <- numeric(0)
1329 comparison[[loser_pct_label_col]] <- character(0)
1330 comparison[[loser_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001331 if (has_urls) {
1332 comparison[[loser_pct_url_col]] <- character(0)
1333 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001334 comparison[[max_delta_pct_col]] <- numeric(0)
1335 next
1336 }
1337
1338 pct_matrix <- as.matrix(pct_values)
1339 storage.mode(pct_matrix) <- "numeric"
1340
1341 n_rows <- nrow(pct_matrix)
1342 winner_labels <- rep(NA_character_, n_rows)
1343 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001344 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001345 runner_labels <- rep(NA_character_, n_rows)
1346 runner_values <- rep(NA_real_, n_rows)
1347 loser_labels <- rep(NA_character_, n_rows)
1348 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001349 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001350 max_deltas <- rep(NA_real_, n_rows)
1351
1352 if (n_rows > 0) {
1353 for (i in seq_len(n_rows)) {
1354 numeric_row <- as.numeric(pct_matrix[i, ])
1355 if (all(is.na(numeric_row))) {
1356 next
1357 }
1358
1359 if (any(is.na(numeric_row))) {
1360 numeric_row[is.na(numeric_row)] <- 0
1361 }
1362 pct_matrix[i, ] <- numeric_row
1363
1364 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1365 max_idx <- which(numeric_row == max_val)
1366 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
1367 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001368 if (has_urls) {
1369 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1370 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001371
1372 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1373 if (length(unique_vals) >= 2) {
1374 runner_val <- unique_vals[2]
1375 runner_idx <- which(numeric_row == runner_val)
1376 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
1377 runner_values[i] <- runner_val
1378 }
1379
1380 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1381 min_idx <- which(numeric_row == min_val)
1382 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
1383 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001384 if (has_urls) {
1385 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1386 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001387
1388 if (is.finite(max_val) && is.finite(min_val)) {
1389 max_deltas[i] <- max_val - min_val
1390 }
1391 }
1392 }
1393
1394 comparison[, pct_cols] <- pct_matrix
1395 comparison[[winner_pct_label_col]] <- winner_labels
1396 comparison[[winner_pct_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001397 if (has_urls) {
1398 comparison[[winner_pct_url_col]] <- winner_urls
1399 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001400 comparison[[runner_pct_label_col]] <- runner_labels
1401 comparison[[runner_pct_value_col]] <- runner_values
1402 comparison[[loser_pct_label_col]] <- loser_labels
1403 comparison[[loser_pct_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001404 if (has_urls) {
1405 comparison[[loser_pct_url_col]] <- loser_urls
1406 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001407 comparison[[max_delta_pct_col]] <- max_deltas
1408 }
1409
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +02001410 for (flag_col in names(imputed_flags)) {
1411 comparison[[flag_col]] <- imputed_flags[[flag_col]]
1412 }
1413 if (length(imputed_flags) > 0) {
1414 comparison$n_imputed <- as.integer(Reduce(`+`, lapply(imputed_flags, as.integer)))
1415 } else {
1416 comparison$n_imputed <- rep(0L, nrow(comparison))
1417 }
1418 comparison$imputed <- comparison$n_imputed > 0L
1419
Marc Kupietz424cb782026-08-31 10:19:29 +02001420 n_imputed_rows <- sum(comparison$imputed)
1421 if (n_imputed_rows > 0) {
1422 log_info(verbose, sprintf(
1423 paste0(
1424 "Imputed scores for %d of %d node/collocate combinations (%d of %d label cells) ",
1425 "that are not attested in every virtual corpus. Their delta and winner/loser ",
1426 "columns reflect presence vs. absence rather than a measured contrast; see the ",
1427 "`imputed` column and `queryMissingScores`.\n"
1428 ),
1429 n_imputed_rows,
1430 nrow(comparison),
1431 sum(comparison$n_imputed),
1432 nrow(comparison) * length(labels)
1433 ))
1434 }
1435
Marc Kupietz09b1c082026-05-01 14:45:47 +02001436 collapse_consensus_url_columns <- function(url_cols) {
1437 if (length(url_cols) == 0) {
1438 return(rep(NA_character_, nrow(comparison)))
1439 }
1440 vapply(seq_len(nrow(comparison)), function(i) {
1441 urls <- unlist(comparison[i, url_cols, drop = FALSE], use.names = FALSE)
1442 urls <- as.character(urls)
1443 urls <- urls[!is.na(urls) & urls != ""]
1444 urls <- unique(urls)
1445 if (length(urls) == 1) {
1446 urls
1447 } else {
1448 NA_character_
1449 }
1450 }, character(1))
1451 }
1452
1453 winner_score_url_cols <- intersect(paste0("winner_", score_cols, "_webUIRequestUrl"), names(comparison))
1454 loser_score_url_cols <- intersect(paste0("loser_", score_cols, "_webUIRequestUrl"), names(comparison))
1455 if (length(winner_score_url_cols) > 0) {
1456 comparison$winner_webUIRequestUrl <- collapse_consensus_url_columns(winner_score_url_cols)
1457 }
1458 if (length(loser_score_url_cols) > 0) {
1459 comparison$loser_webUIRequestUrl <- collapse_consensus_url_columns(loser_score_url_cols)
1460 }
1461
1462 url_helper_cols <- intersect(paste0("webUIRequestUrl_", labels), names(comparison))
1463 if (length(url_helper_cols) > 0) {
1464 comparison <- dplyr::select(comparison, -dplyr::all_of(url_helper_cols))
1465 }
1466
Marc Kupietzc4540a22025-10-14 17:39:53 +02001467 dplyr::left_join(result, comparison, by = c("node", "collocate"))
1468}
1469
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001470#' @importFrom magrittr debug_pipe
Marc Kupietz2b17b212023-08-27 17:47:26 +02001471#' @importFrom stringr str_detect
1472#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1473#'
1474matches2FreqTable <- function(matches,
1475 index = 0,
1476 minOccur = 5,
1477 leftContextSize = 5,
1478 rightContextSize = 5,
1479 ignoreCollocateCase = FALSE,
1480 stopwords = c(),
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001481 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz2b17b212023-08-27 17:47:26 +02001482 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1483 verbose = TRUE) {
1484 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1485 frequency <- NULL
1486
1487 if (nrow(matches) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001488 dplyr::tibble(word = c(), frequency = c())
Marc Kupietz2b17b212023-08-27 17:47:26 +02001489 } else if (index == 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001490 if (!"tokens" %in% colnames(matches) || !is.list(matches$tokens)) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001491 log_info(verbose, "Outdated KorAP server: Falling back to client side tokenization.\n")
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001492 return(snippet2FreqTable(matches$snippet, minOccur, leftContextSize, rightContextSize,
1493 ignoreCollocateCase = ignoreCollocateCase,
1494 stopwords = stopwords, oldTable = oldTable, verbose = verbose
1495 ))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001496 }
1497 log_info(verbose, paste("Joining", nrow(matches), "kwics\n"))
Marc Kupietza25fbd92025-10-14 17:38:09 +02001498 for (i in seq_len(nrow(matches))) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001499 oldTable <- matches2FreqTable(
1500 matches,
1501 i,
1502 leftContextSize = leftContextSize,
1503 rightContextSize = rightContextSize,
1504 collocateFilterRegex = collocateFilterRegex,
1505 oldTable = oldTable,
1506 stopwords = stopwords
1507 )
1508 }
1509 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001510 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001511 group_by(word) |>
1512 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001513 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001514 arrange(desc(frequency))
1515 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001516 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietz2b17b212023-08-27 17:47:26 +02001517
1518 left <- tail(unlist(matches$tokens$left[index]), leftContextSize)
1519
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001520 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001521
1522 right <- head(unlist(matches$tokens$right[index]), rightContextSize)
1523
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001524 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001525
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001526 if (length(left) + length(right) == 0) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001527 oldTable
1528 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001529 table(c(left, right)) |>
1530 dplyr::as_tibble(.name_repair = "minimal") |>
1531 dplyr::rename(word = 1, frequency = 2) |>
1532 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001533 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001534 dplyr::bind_rows(oldTable)
1535 }
1536 }
1537}
1538
1539#' @importFrom magrittr debug_pipe
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001540#' @importFrom stringr str_match str_split str_detect
1541#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1542#'
1543snippet2FreqTable <- function(snippet,
1544 minOccur = 5,
1545 leftContextSize = 5,
1546 rightContextSize = 5,
1547 ignoreCollocateCase = FALSE,
1548 stopwords = c(),
1549 tokenizeRegex = "([! )(\uc2\uab,.:?\u201e\u201c\'\"]+|&quot;)",
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001550 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001551 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1552 verbose = TRUE) {
1553 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1554 frequency <- NULL
1555
1556 if (length(snippet) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001557 dplyr::tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001558 } else if (length(snippet) > 1) {
Marc Kupietza47d1502023-04-18 15:26:47 +02001559 log_info(verbose, paste("Joining", length(snippet), "kwics\n"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001560 for (s in snippet) {
1561 oldTable <- snippet2FreqTable(
1562 s,
1563 leftContextSize = leftContextSize,
1564 rightContextSize = rightContextSize,
Marc Kupietz47d0d2b2021-12-19 16:38:52 +01001565 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001566 oldTable = oldTable,
1567 stopwords = stopwords
1568 )
1569 }
Marc Kupietza47d1502023-04-18 15:26:47 +02001570 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001571 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001572 group_by(word) |>
1573 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001574 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001575 arrange(desc(frequency))
1576 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001577 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001578 match <-
1579 str_match(
1580 snippet,
1581 '<span class="context-left">(<span class="more"></span>)?(.*[^ ]) *</span><span class="match"><mark>.*</mark></span><span class="context-right"> *([^<]*)'
1582 )
1583
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001584 left <- if (leftContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001585 tail(unlist(str_split(match[1, 3], tokenizeRegex)), leftContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001586 } else {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001587 ""
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001588 }
1589 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001590
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001591 right <- if (rightContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001592 head(unlist(str_split(match[1, 4], tokenizeRegex)), rightContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001593 } else {
1594 ""
1595 }
1596 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001597
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001598 if (is.na(left[1]) || is.na(right[1]) || length(left) + length(right) == 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001599 oldTable
1600 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001601 table(c(left, right)) |>
1602 dplyr::as_tibble(.name_repair = "minimal") |>
1603 dplyr::rename(word = 1, frequency = 2) |>
1604 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001605 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001606 dplyr::bind_rows(oldTable)
1607 }
1608 }
1609}
1610
1611#' Preliminary synsemantic stopwords function
1612#'
1613#' @description
Marc Kupietz67edcb52021-09-20 21:54:24 +02001614#' `r lifecycle::badge("experimental")`
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001615#'
1616#' Preliminary synsemantic stopwords function to be used in collocation analysis.
1617#'
1618#' @details
1619#' Currently only suitable for German. See stopwords package for other languages.
1620#'
1621#' @param ... future arguments for language detection
1622#'
1623#' @family collocation analysis functions
1624#' @return Vector of synsemantic stopwords.
1625#' @export
1626synsemanticStopwords <- function(...) {
Marc Kupietzc79155b2025-10-19 13:42:55 +02001627 base <- c(
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001628 "der",
1629 "die",
1630 "und",
1631 "in",
1632 "den",
1633 "von",
1634 "mit",
1635 "das",
1636 "zu",
1637 "im",
1638 "ist",
1639 "auf",
1640 "sich",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001641 "des",
1642 "dem",
1643 "nicht",
1644 "ein",
1645 "eine",
1646 "es",
1647 "auch",
1648 "an",
1649 "als",
1650 "am",
1651 "aus",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001652 "bei",
1653 "er",
1654 "dass",
1655 "sie",
1656 "nach",
1657 "um",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001658 "zum",
1659 "noch",
1660 "war",
1661 "einen",
1662 "einer",
1663 "wie",
1664 "einem",
1665 "vor",
1666 "bis",
1667 "\u00fcber",
1668 "so",
1669 "aber",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001670 "diese",
Marc Kupietzc79155b2025-10-19 13:42:55 +02001671 "oder"
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001672 )
Marc Kupietzc79155b2025-10-19 13:42:55 +02001673
1674 lower <- unique(tolower(base))
1675 capitalized <- paste0(toupper(substr(lower, 1, 1)), substring(lower, 2))
1676
1677 unique(c(lower, capitalized))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001678}
1679
Marc Kupietz5a336b62021-11-27 17:51:35 +01001680
Marc Kupietz76b05592021-12-19 16:26:15 +01001681# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +01001682findExample <-
1683 function(kco,
1684 query,
1685 vc = "",
1686 matchOnly = TRUE) {
1687 out <- character(length = length(query))
1688
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001689 if (length(vc) < length(query)) {
Marc Kupietz5a336b62021-11-27 17:51:35 +01001690 vc <- rep(vc, length(query))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001691 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001692
1693 for (i in seq_along(query)) {
1694 q <- corpusQuery(kco, paste0("(", query[i], ")"), vc = vc[i], metadataOnly = FALSE)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001695 if (q@totalResults > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001696 q <- fetchNext(q, maxFetch = 50, randomizePageOrder = F)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001697 example <- as.character((q@collectedMatches)$snippet[1])
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001698 out[i] <- if (matchOnly) {
1699 gsub(".*<mark>(.+)</mark>.*", "\\1", example)
Marc Kupietz5a336b62021-11-27 17:51:35 +01001700 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001701 stringr::str_replace(example, "<[^>]*>", "")
Marc Kupietz5a336b62021-11-27 17:51:35 +01001702 }
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001703 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001704 out[i] <- ""
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001705 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001706 }
1707 out
1708 }
1709
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001710collocatesQuery <-
1711 function(kco,
1712 query,
1713 vc = "",
1714 minOccur = 5,
1715 leftContextSize = 5,
1716 rightContextSize = 5,
1717 searchHitsSampleLimit = 20000,
1718 ignoreCollocateCase = FALSE,
1719 stopwords = c(),
Marc Kupietzb2862d42025-10-18 10:17:49 +02001720 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001721 ...) {
1722 frequency <- NULL
1723 q <- corpusQuery(kco, query, vc, metadataOnly = F, ...)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001724 if (q@totalResults == 0) {
1725 tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001726 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001727 q <- fetchNext(q, maxFetch = searchHitsSampleLimit, randomizePageOrder = TRUE)
1728 matches2FreqTable(q@collectedMatches,
1729 0,
1730 minOccur = minOccur,
1731 leftContextSize = leftContextSize,
1732 rightContextSize = rightContextSize,
1733 ignoreCollocateCase = ignoreCollocateCase,
1734 stopwords = stopwords,
Marc Kupietzb2862d42025-10-18 10:17:49 +02001735 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001736 ...,
1737 verbose = kco@verbose
1738 ) |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001739 mutate(frequency = frequency * q@totalResults / min(q@totalResults, searchHitsSampleLimit)) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001740 filter(frequency >= minOccur)
1741 }
1742 }