blob: c9b8695b6d5f75c245d3a4a03c18dee7b831ec01 [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 Kupietz1d400f62026-09-03 14:46:16 +02004#' Keep only collocates that are attested often enough relative to expectation
5#'
6#' Rows without an expected frequency are kept, as are all rows if the ratio is
7#' 0 or `NULL`, which switches the filter off.
8#'
9#' @param result collocation analysis result
10#' @param minObservedExpectedRatio minimum ratio of observed to expected
11#' co-occurrence frequency
12#' @return `result` without the rows that fall below the ratio
13#' @noRd
14filterByObservedExpectedRatio <- function(result, minObservedExpectedRatio) {
15 if (is.null(minObservedExpectedRatio) || is.na(minObservedExpectedRatio) ||
16 minObservedExpectedRatio <= 0 || nrow(result) == 0 ||
17 !all(c("O", "E") %in% names(result))) {
18 return(result)
19 }
20 result[is.na(result$E) | result$O >= minObservedExpectedRatio * result$E, , drop = FALSE]
21}
22
Marc Kupietz37f96072026-09-03 07:18:11 +020023#' Name of the attribute under which cache files record their analysis parameters
24#' @noRd
25collocationCacheAttribute <- "RKorAPClient.collocationAnalysis"
26
27#' Parameters that a cached collocation analysis was computed with
28#'
29#' Collected from the calling `collocationAnalysis()` frame, so that parameters
30#' added in the future are taken into account automatically. `kco` and `cacheAs`
31#' are excluded: the former is not a parameter of the analysis, the latter only
32#' says where to store it.
33#'
34#' @param frame environment of the `collocationAnalysis()` call
35#' @param dots arguments passed on to [collocationScoreQuery()]
36#' @param kco [KorAPConnection()] object
37#' @return list of parameters to store with, and compare against, a cache file
38#' @noRd
39collocationCacheParameters <- function(frame, dots, kco) {
40 parameterNames <- setdiff(
41 names(formals(sys.function(sys.parent()))),
42 c("kco", "cacheAs", "...")
43 )
44 list(
45 parameters = mget(parameterNames, envir = frame),
46 dots = dots,
47 # reusing one cache file for two KorAP instances is a mistake worth catching
48 apiUrl = kco@apiUrl,
49 # recorded for reference only, deliberately not compared: corpus updates
50 # should not invalidate a deliberately kept analysis
51 indexRevision = kco@indexRevision
52 )
53}
54
55#' Parameters in which a cached collocation analysis differs from the current call
56#'
57#' @param stored parameters recorded in the cache file
58#' @param current parameters of the current call
59#' @return names of the differing parameters, empty if the cache is still valid
60#' @noRd
61differingCollocationCacheParameters <- function(stored, current) {
62 differing <- character(0)
63
64 for (name in union(names(stored$parameters), names(current$parameters))) {
65 if (!identical(stored$parameters[[name]], current$parameters[[name]])) {
66 differing <- c(differing, name)
67 }
68 }
69 if (!identical(stored$dots, current$dots)) {
70 differing <- c(differing, "...")
71 }
72 if (!identical(stored$apiUrl, current$apiUrl)) {
73 differing <- c(differing, "KorAP instance")
74 }
75
76 differing
77}
78
Marc Kupietzdbd431a2021-08-29 12:17:45 +020079#' Collocation analysis
80#'
Marc Kupietza8c40f42025-06-24 15:49:52 +020081#' @family collocation analysis functions
Marc Kupietzdbd431a2021-08-29 12:17:45 +020082#' @aliases collocationAnalysis
83#'
84#' @description
Marc Kupietzdbd431a2021-08-29 12:17:45 +020085#'
86#' Performs a collocation analysis for the given node (or query)
87#' in the given virtual corpus.
88#'
89#' @details
90#' The collocation analysis is currently implemented on the client side, as some of the
91#' functionality is not yet provided by the KorAP backend. Mainly for this reason
92#' it is very slow (several minutes, up to hours), but on the other hand very flexible.
93#' You can, for example, perform the analysis in arbitrary virtual corpora, use complex node queries,
94#' and look for expression-internal collocates using the focus function (see examples and demo).
95#'
96#' To increase speed at the cost of accuracy and possible false negatives,
97#' you can decrease searchHitsSampleLimit and/or topCollocatesLimit and/or set exactFrequencies to FALSE.
98#'
Marc Kupietze7f0d682025-02-19 10:50:59 +010099#' Note that some outdated non-DeReKo back-ends might not yet support returning tokenized matches (warning issued).
100#' In this case, the client library will fall back to client-side tokenization which might be slightly less accurate.
101#' 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 +0200102#' user interface.
103#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200104#'
Marc Kupietz67edcb52021-09-20 21:54:24 +0200105#' @param lemmatizeNodeQuery if TRUE, node query will be lemmatized, i.e. `x -> [tt/l=x]`
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200106#' @param minOccur minimum absolute number of observed co-occurrences to consider a collocate candidate
107#' @param topCollocatesLimit limit analysis to the n most frequent collocates in the search hits sample
108#' @param searchHitsSampleLimit limit the size of the search hits sample
109#' @param stopwords vector of stopwords not to be considered as collocates
Marc Kupietz6bd9cad2024-12-18 15:57:26 +0100110#' @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 +0200111#' @param exactFrequencies if FALSE, extrapolate observed co-occurrence frequencies from frequencies in search hits sample, otherwise retrieve exact co-occurrence frequencies
112#' @param seed seed for random page collecting order
Marc Kupietz67edcb52021-09-20 21:54:24 +0200113#' @param expand if TRUE, `node` and `vc` parameters are expanded to all of their combinations
Marc Kupietz7d400e02021-12-19 16:39:36 +0100114#' @param maxRecurse apply collocation analysis recursively `maxRecurse` times
115#' @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 +0200116#' @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 +0200117#' @param threshold minimum value of `thresholdScore` function call to apply collocation analysis recursively (only applied when \code{maxRecurse > 0}).
118#' Note that the default score, `logDice`, expresses how salient a pair is
119#' rather than how surprising, so that a frequent collocate can pass it while
Marc Kupietz1d400f62026-09-03 14:46:16 +0200120#' co-occurring less often than expected. `minObservedExpectedRatio` keeps
121#' those out. See the "Salience versus surprise" section of
122#' \code{\link{association-score-functions}}.
123#' @param minObservedExpectedRatio minimum ratio of observed to expected co-occurrence
124#' frequency a collocate must reach. Defaults to 1, which keeps only collocates
125#' that occur at least as often as expected by chance, corresponding to a
126#' non-negative `pmi`. Without it, frequent words can end up among the top
127#' collocates by `logDice` although the node does not attract them at all (see
128#' the "Salience versus surprise" section of
129#' \code{\link{association-score-functions}}). Raise it to demand a stronger
130#' contrast, e.g. 2 for collocates occurring at least twice as often as
131#' expected, or set it to 0 to switch the filter off and obtain the unfiltered
132#' result of earlier versions, e.g. in order to study repulsion.
Marc Kupietz7d400e02021-12-19 16:39:36 +0100133#' @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 +0100134#' @param collocateFilterRegex allow only collocates matching the regular expression
Marc Kupietzde679ea2025-10-19 13:14:51 +0200135#' @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 +0200136#' @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 +0200137#' @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 +0200138#' @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 +0200139#'
140#' The analysis parameters are stored alongside the result. If they differ from
141#' those of the current call, the cached result would not be the one that was
142#' asked for, so it is recomputed and the file overwritten, with a warning
143#' naming the parameters that differ. Pass a different \code{cacheAs} file name
144#' to keep an existing analysis. Cache files written by RKorAPClient 1.3.0 do
145#' not contain the parameters yet and are used as they are.
Marc Kupietz67edcb52021-09-20 21:54:24 +0200146#' @param ... more arguments will be passed to [collocationScoreQuery()]
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200147#' @inheritParams collocationScoreQuery,KorAPConnection-method
Marc Kupietz130a2a22025-10-18 16:09:23 +0200148#' @return
149#' A tibble where each row represents a candidate collocate for the requested node.
150#' Columns include (depending on the selected association measures):
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200151#'
Marc Kupietz130a2a22025-10-18 16:09:23 +0200152#' \itemize{
153#' \item \code{node}, \code{collocate}, \code{vc}, \code{label}: identifiers for the query node, collocate, virtual corpus, and optional label.
154#' \item Frequency and contingency information such as \code{frequency}, \code{O}, \code{O1}, \code{O2}, \code{E}, \code{leftContextSize}, \code{rightContextSize}, and \code{w}.
155#' \item Association measures (e.g. \code{logDice}, \code{ll}, \code{mi}, ...), one column per requested scorer.
156#' \item Per-labelled association scores produced by multi-VC comparisons using the pattern \code{<measure>_<label>}.
157#' \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>}.
158#' \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 +0200159#' \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 +0200160#' \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 +0200161#' \item Optional helper columns such as \code{query}, \code{example}, or \code{url} when example retrieval is requested.
162#' }
Marc Kupietz95253342026-08-31 10:18:43 +0200163#' @section Interpreting multi-VC comparisons:
164#'
Marc Kupietzba15cff2026-08-31 10:19:56 +0200165#' `r lifecycle::badge("experimental")`
166#'
167#' The comparison columns produced when `vc` holds more than one virtual corpus
168#' are experimental: their names and semantics may still change in a future
169#' release without a deprecation cycle. Code that has to keep working across
170#' versions should select the columns it needs explicitly.
171#'
172#' They are an exploration aid, not a significance test. When reading them, keep
173#' three properties in mind.
Marc Kupietz95253342026-08-31 10:18:43 +0200174#'
175#' \strong{Imputed scores describe presence/absence, not contrast.} A collocate
176#' that passes the `minOccur` and `topCollocatesLimit` thresholds in one virtual
177#' corpus but not in another has no observed score for the latter. Such cells are
178#' imputed from a floor derived from the pooled result set (see
179#' `missingScoreQuantile`), so the corresponding `delta_*` and `max_delta_*`
180#' values measure the distance to that floor rather than an attested difference.
181#' The `imputed`, `n_imputed` and `imputed_<label>` columns mark these rows;
182#' `dplyr::filter(!imputed)` restricts the result to collocates attested
183#' everywhere, and `queryMissingScores = TRUE` replaces most imputed cells with
184#' scores actually retrieved from the backend.
185#'
186#' \strong{Imputed values are relative to one analysis.} The floor is computed
187#' from the scores present in the result at hand. Analysing a node on its own and
188#' analysing it together with other nodes therefore yield different imputed
189#' values, and deltas involving imputed cells are not comparable across separate
190#' calls. Deltas between observed scores are unaffected.
191#'
192#' \strong{Winners carry no uncertainty.} Unlike [ci()], which attaches
193#' confidence intervals to relative frequencies, the `winner_*` / `loser_*`
194#' columns simply order point estimates. A collocate wins by a hair on six
195#' occurrences exactly as decisively as one that wins by a wide margin on
196#' thousands. Consult the observed frequencies (`O`, `O1`, `O2`) and the
197#' `webUIRequestUrl` concordance links before drawing conclusions from a
198#' small difference.
199#'
200#' Note also that `rank_<label>_<measure>` and
201#' `percentile_rank_<label>_<measure>` are computed within each label, over that
202#' label's own candidate set. Candidate sets usually differ in size between
203#' virtual corpora, so rank-based deltas compare positions in populations of
204#' different sizes.
Marc Kupietzc4540a22025-10-14 17:39:53 +0200205#' @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 +0200206#' @importFrom purrr pmap
Marc Kupietzc4540a22025-10-14 17:39:53 +0200207#' @importFrom tidyr expand_grid pivot_wider
208#' @importFrom rlang sym
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200209#'
210#' @examples
Marc Kupietz6ae76052021-09-21 10:34:00 +0200211#' \dontrun{
212#'
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200213#' # Find top collocates of "Packung" inside and outside the sports domain.
214#' KorAPConnection(verbose = TRUE) |>
215#' collocationAnalysis("Packung",
216#' vc = c("textClass=sport", "textClass!=sport"),
217#' leftContextSize = 1, rightContextSize = 1, topCollocatesLimit = 20
218#' ) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200219#' dplyr::filter(logDice >= 5)
220#' }
221#'
Marc Kupietz6ae76052021-09-21 10:34:00 +0200222#' \dontrun{
223#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200224#' # Identify the most prominent light verb construction with "in ... setzen".
225#' # Note that, currently, the use of focus function disallows exactFrequencies.
Marc Kupietz4cd066d2025-02-28 15:48:23 +0100226#' KorAPConnection(verbose = TRUE) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200227#' collocationAnalysis("focus(in [tt/p=NN] {[tt/l=setzen]})",
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200228#' leftContextSize = 1, rightContextSize = 0, exactFrequencies = FALSE, topCollocatesLimit = 20
229#' )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200230#' }
231#'
232#' @export
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200233setMethod(
234 "collocationAnalysis", "KorAPConnection",
235 function(kco,
236 node,
237 vc = "",
238 lemmatizeNodeQuery = FALSE,
239 minOccur = 5,
240 leftContextSize = 5,
241 rightContextSize = 5,
242 topCollocatesLimit = 200,
243 searchHitsSampleLimit = 20000,
244 ignoreCollocateCase = FALSE,
245 withinSpan = ifelse(exactFrequencies, "base/s=s", ""),
246 exactFrequencies = TRUE,
247 stopwords = append(RKorAPClient::synsemanticStopwords(), node),
248 seed = 7,
249 expand = length(vc) != length(node),
250 maxRecurse = 0,
251 addExamples = FALSE,
252 thresholdScore = "logDice",
253 threshold = 2.0,
254 localStopwords = c(),
255 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz1d400f62026-09-03 14:46:16 +0200256 minObservedExpectedRatio = 1,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200257 queryMissingScores = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200258 missingScoreQuantile = 0.05,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200259 vcLabel = NA_character_,
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200260 cacheAs = NULL,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200261 ...) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200262 word <- frequency <- O <- NULL
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200263
Marc Kupietz37f96072026-09-03 07:18:11 +0200264 cacheParameters <- NULL
265 if (!is.null(cacheAs)) {
266 if (!grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
267 cacheAs <- paste0(cacheAs, ".rds")
268 }
269 cacheParameters <- collocationCacheParameters(environment(), list(...), kco)
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200270 }
271
272 if (!is.null(cacheAs) && file.exists(cacheAs)) {
Marc Kupietz37f96072026-09-03 07:18:11 +0200273 cached <- readRDS(cacheAs)
274 storedParameters <- attr(cached, collocationCacheAttribute)
275 attr(cached, collocationCacheAttribute) <- NULL
276
277 if (is.null(storedParameters)) {
278 # written before parameter checking existed, so there is nothing to check
279 log_info(kco@verbose, sprintf(
280 "Loading collocation analysis from cache (written without parameters): %s\n", cacheAs
281 ))
282 return(cached)
283 }
284
285 differing <- differingCollocationCacheParameters(storedParameters, cacheParameters)
286 if (length(differing) == 0) {
287 log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
288 return(cached)
289 }
290
291 warning(
292 sprintf(
293 paste0(
294 "Cache file '%s' was created with different parameters (%s) and is recomputed and overwritten.\n",
295 "Pass a different cacheAs file name to keep the cached analysis."
296 ),
297 cacheAs, paste(differing, collapse = ", ")
298 ),
299 call. = FALSE
300 )
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200301 }
302
Marc Kupietzb2862d42025-10-18 10:17:49 +0200303 if (!exactFrequencies && (!is.na(withinSpan) && !is.null(withinSpan) && nzchar(withinSpan))) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200304 stop(sprintf("Not empty withinSpan (='%s') requires exactFrequencies=TRUE", withinSpan), call. = FALSE)
305 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200306
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200307 warnIfNotAuthorized(kco)
Marc Kupietz581a29b2021-09-04 20:51:04 +0200308
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200309 if (lemmatizeNodeQuery) {
310 node <- lemmatizeWordQuery(node)
311 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200312
Marc Kupietze34a8be2025-10-17 20:13:42 +0200313 vcNames <- names(vc)
Marc Kupietze34a8be2025-10-17 20:13:42 +0200314 if (is.null(vcNames)) {
315 vcNames <- rep(NA_character_, length(vc))
Marc Kupietze34a8be2025-10-17 20:13:42 +0200316 }
317
318 label_lookup <- NULL
Marc Kupietzb2862d42025-10-18 10:17:49 +0200319 if (!is.null(names(vc)) && length(vc) > 0) {
320 raw_names <- names(vc)
321 if (any(!is.na(raw_names) & raw_names != "")) {
322 label_lookup <- stats::setNames(raw_names, vc)
323 }
Marc Kupietze34a8be2025-10-17 20:13:42 +0200324 }
325
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200326 result <- if (length(node) > 1 || length(vc) > 1) {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200327 grid <- if (expand) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200328 tmp_grid <- tidyr::expand_grid(node = node, idx = seq_along(vc))
329 tmp_grid$vc <- vc[tmp_grid$idx]
330 tmp_grid$vcLabel <- vcNames[tmp_grid$idx]
331 tmp_grid[, c("node", "vc", "vcLabel"), drop = FALSE]
Marc Kupietze34a8be2025-10-17 20:13:42 +0200332 } else {
333 tibble(node = node, vc = vc, vcLabel = vcNames)
334 }
335
336 multi_result <- purrr::pmap(grid, function(node, vc, vcLabel, ...) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200337 collocationAnalysis(kco,
338 node = node,
339 vc = vc,
340 minOccur = minOccur,
Marc Kupietz1d400f62026-09-03 14:46:16 +0200341 minObservedExpectedRatio = minObservedExpectedRatio,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200342 leftContextSize = leftContextSize,
343 rightContextSize = rightContextSize,
344 topCollocatesLimit = topCollocatesLimit,
345 searchHitsSampleLimit = searchHitsSampleLimit,
346 ignoreCollocateCase = ignoreCollocateCase,
347 withinSpan = withinSpan,
348 exactFrequencies = exactFrequencies,
349 stopwords = stopwords,
350 addExamples = TRUE,
351 localStopwords = localStopwords,
352 seed = seed,
353 expand = expand,
Marc Kupietz9894a372025-10-18 14:51:29 +0200354 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200355 queryMissingScores = queryMissingScores,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200356 collocateFilterRegex = collocateFilterRegex,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200357 vcLabel = vcLabel,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200358 ...
359 )
360 }) |>
Marc Kupietze31322e2025-10-17 18:55:36 +0200361 bind_rows()
362
363 if (!"vc" %in% names(multi_result) || nrow(multi_result) == 0) {
364 multi_result
365 } else {
Marc Kupietzde679ea2025-10-19 13:14:51 +0200366 if (queryMissingScores) {
367 multi_result <- backfill_missing_scores(
368 multi_result,
369 grid = grid,
370 kco = kco,
371 ignoreCollocateCase = ignoreCollocateCase,
372 ...
373 )
374 }
375
Marc Kupietze34a8be2025-10-17 20:13:42 +0200376 if (!"label" %in% names(multi_result)) {
377 multi_result$label <- NA_character_
378 }
379
380 if (!is.null(label_lookup)) {
381 override <- unname(label_lookup[multi_result$vc])
382 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
383 if (any(missing_idx)) {
384 multi_result$label[missing_idx] <- override[missing_idx]
385 }
386 }
387
388 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
389 if (any(missing_idx)) {
390 multi_result$label[missing_idx] <- queryStringToLabel(multi_result$vc[missing_idx])
391 }
392
Marc Kupietze31322e2025-10-17 18:55:36 +0200393 multi_result |>
Marc Kupietz9894a372025-10-18 14:51:29 +0200394 add_multi_vc_comparisons(
Marc Kupietz424cb782026-08-31 10:19:29 +0200395 missingScoreQuantile = missingScoreQuantile,
396 verbose = kco@verbose
Marc Kupietz9894a372025-10-18 14:51:29 +0200397 )
Marc Kupietze31322e2025-10-17 18:55:36 +0200398 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200399 } else {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200400 if ((is.na(vcLabel) || vcLabel == "") && length(vcNames) >= 1) {
401 vcLabel <- vcNames[1]
402 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200403
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200404 set.seed(seed)
405 candidates <- collocatesQuery(
406 kco,
407 node,
408 vc = vc,
409 minOccur = minOccur,
410 leftContextSize = leftContextSize,
411 rightContextSize = rightContextSize,
412 searchHitsSampleLimit = searchHitsSampleLimit,
413 ignoreCollocateCase = ignoreCollocateCase,
414 stopwords = append(stopwords, localStopwords),
Marc Kupietzb2862d42025-10-18 10:17:49 +0200415 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200416 ...
417 )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200418
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200419 if (nrow(candidates) > 0) {
420 candidates <- candidates |>
421 filter(frequency >= minOccur) |>
422 slice_head(n = topCollocatesLimit)
423 collocationScoreQuery(
424 kco,
425 node = node,
426 collocate = candidates$word,
427 vc = vc,
428 leftContextSize = leftContextSize,
429 rightContextSize = rightContextSize,
430 observed = if (exactFrequencies) NA else candidates$frequency,
431 ignoreCollocateCase = ignoreCollocateCase,
432 withinSpan = withinSpan,
433 ...
434 ) |>
435 filter(O >= minOccur) |>
Marc Kupietz1d400f62026-09-03 14:46:16 +0200436 filterByObservedExpectedRatio(minObservedExpectedRatio) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200437 dplyr::arrange(dplyr::desc(logDice))
438 } else {
439 tibble()
440 }
441 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200442
443 if (!is.na(vcLabel) && vcLabel != "" && "label" %in% names(result)) {
444 result$label <- rep(vcLabel, nrow(result))
445 }
446
447 threshold_col <- thresholdScore
448 if (maxRecurse > 0 && nrow(result) > 0 && threshold_col %in% names(result)) {
449 threshold_values <- result[[threshold_col]]
450 eligible_idx <- which(!is.na(threshold_values) & threshold_values >= threshold)
451 if (length(eligible_idx) > 0) {
452 recurseWith <- result[eligible_idx, , drop = FALSE]
453 result <- collocationAnalysis(
454 kco,
455 node = paste0("(", buildCollocationQuery(
456 removeWithinSpan(recurseWith$node, withinSpan),
457 recurseWith$collocate,
458 leftContextSize = leftContextSize,
459 rightContextSize = rightContextSize,
460 withinSpan = ""
461 ), ")"),
462 vc = vc,
463 minOccur = minOccur,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200464 leftContextSize = leftContextSize,
465 rightContextSize = rightContextSize,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200466 withinSpan = withinSpan,
467 maxRecurse = maxRecurse - 1,
Marc Kupietz1d400f62026-09-03 14:46:16 +0200468 minObservedExpectedRatio = minObservedExpectedRatio,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200469 stopwords = stopwords,
470 localStopwords = recurseWith$collocate,
471 exactFrequencies = exactFrequencies,
472 searchHitsSampleLimit = searchHitsSampleLimit,
473 topCollocatesLimit = topCollocatesLimit,
474 addExamples = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200475 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200476 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200477 queryMissingScores = queryMissingScores,
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200478 thresholdScore = thresholdScore,
479 threshold = threshold,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200480 vcLabel = vcLabel
481 ) |>
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200482 bind_rows(result)
483
484 if (threshold_col %in% names(result)) {
485 threshold_values <- result[[threshold_col]]
486 keep_idx <- is.na(threshold_values) | threshold_values >= threshold
487 result <- result[keep_idx, , drop = FALSE]
488 }
489
490 result <- result |>
Marc Kupietzb2862d42025-10-18 10:17:49 +0200491 filter(O >= minOccur) |>
Marc Kupietz1d400f62026-09-03 14:46:16 +0200492 filterByObservedExpectedRatio(minObservedExpectedRatio) |>
Marc Kupietzb2862d42025-10-18 10:17:49 +0200493 dplyr::arrange(dplyr::desc(logDice))
494 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200495 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200496
497 if (addExamples && nrow(result) > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200498 result$query <- buildCollocationQuery(
499 result$node,
500 result$collocate,
501 leftContextSize = leftContextSize,
502 rightContextSize = rightContextSize,
503 withinSpan = withinSpan
504 )
505 result$example <- findExample(
506 kco,
507 query = result$query,
508 vc = result$vc
509 )
510 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200511
Marc Kupietz0a292632025-10-19 14:04:36 +0200512 if (!is.null(withinSpan) && !is.na(withinSpan) && nzchar(withinSpan) &&
513 nrow(result) > 0 &&
514 "webUIRequestUrl" %in% names(result) &&
515 "query" %in% names(result)) {
516 candidate_rows <- which(!is.na(result$node) &
517 !grepl("focus\\(", result$node, perl = TRUE) &
518 !is.na(result$query) & nzchar(result$query))
519
520 if (length(candidate_rows) > 0) {
521 focused_queries <- vapply(
522 result$query[candidate_rows],
523 inject_focus_into_query,
524 character(1)
525 )
526
527 changed <- focused_queries != result$query[candidate_rows]
528 if (any(changed)) {
529 indices <- candidate_rows[changed]
530 vc_values <- as.character(result$vc)
531 vc_values[is.na(vc_values)] <- ""
532
533 result$webUIRequestUrl[indices] <- mapply(
534 function(new_query, vc_value) {
535 buildWebUIRequestUrlFromString(
536 kco@KorAPUrl,
537 new_query,
538 vc = vc_value,
539 ql = "poliqarp"
540 )
541 },
542 focused_queries[changed],
543 vc_values[indices],
544 USE.NAMES = FALSE
545 )
546 }
547 }
548 }
549
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200550 if (!is.null(cacheAs)) {
551 log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", cacheAs))
Marc Kupietz37f96072026-09-03 07:18:11 +0200552 # only the stored copy carries the parameters, so that the returned value
553 # is the same whether it was cached or not
554 cachedResult <- result
555 attr(cachedResult, collocationCacheAttribute) <- cacheParameters
556 saveRDS(cachedResult, cacheAs)
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200557 }
558
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200559 result
560 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200561)
562
Marc Kupietz76b05592021-12-19 16:26:15 +0100563# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +0100564removeWithinSpan <- function(query, withinSpan) {
565 if (withinSpan == "") {
566 return(query)
567 }
568 needle <- sprintf("^\\(contains\\(<%s>, ?(.*)\\){2}$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200569 res <- gsub(needle, "\\1", query)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100570 needle <- sprintf("^contains\\(<%s>, ?(.*)\\)$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200571 res <- gsub(needle, "\\1", res)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100572 return(res)
573}
574
Marc Kupietzde679ea2025-10-19 13:14:51 +0200575backfill_missing_scores <- function(result,
576 grid,
577 kco,
578 ignoreCollocateCase,
579 ...) {
580 if (!"vc" %in% names(result) || !"node" %in% names(result) || !"collocate" %in% names(result)) {
581 return(result)
582 }
583
584 if (nrow(result) == 0) {
585 return(result)
586 }
587
Marc Kupietz9c53e412026-06-21 12:13:44 +0200588 distinct_pairs <- dplyr::distinct(
589 result,
590 .data$node,
591 .data$collocate
592 )
Marc Kupietzde679ea2025-10-19 13:14:51 +0200593 if (nrow(distinct_pairs) == 0) {
594 return(result)
595 }
596
597 collocates_by_node <- split(as.character(distinct_pairs$collocate), distinct_pairs$node)
598 if (length(collocates_by_node) == 0) {
599 return(result)
600 }
601
602 required_combinations <- unique(as.data.frame(grid[, c("node", "vc", "vcLabel")], drop = FALSE))
603 for (i in seq_len(nrow(required_combinations))) {
604 node_value <- required_combinations$node[i]
605 vc_value <- required_combinations$vc[i]
606
607 collocate_pool <- collocates_by_node[[node_value]]
608 if (is.null(collocate_pool) || length(collocate_pool) == 0) {
609 next
610 }
611
612 existing_idx <- result$node == node_value & result$vc == vc_value
613 existing_collocates <- unique(as.character(result$collocate[existing_idx]))
614 missing_collocates <- setdiff(unique(collocate_pool), existing_collocates)
615 missing_collocates <- missing_collocates[!is.na(missing_collocates) & nzchar(missing_collocates)]
616
617 if (length(missing_collocates) == 0) {
618 next
619 }
620
621 context_rows <- result[result$node == node_value & result$vc == vc_value, , drop = FALSE]
622 if (nrow(context_rows) == 0) {
623 context_rows <- result[result$node == node_value, , drop = FALSE]
624 }
625
626 left_size <- context_rows$leftContextSize[!is.na(context_rows$leftContextSize)][1]
627 if (is.na(left_size) || length(left_size) == 0) {
628 left_size <- result$leftContextSize[!is.na(result$leftContextSize)][1]
629 }
630 if (is.na(left_size) || length(left_size) == 0) {
631 left_size <- 5
632 }
633
634 right_size <- context_rows$rightContextSize[!is.na(context_rows$rightContextSize)][1]
635 if (is.na(right_size) || length(right_size) == 0) {
636 right_size <- result$rightContextSize[!is.na(result$rightContextSize)][1]
637 }
638 if (is.na(right_size) || length(right_size) == 0) {
639 right_size <- 5
640 }
641
642 within_span_value <- ""
643 if ("query" %in% names(context_rows)) {
644 query_candidate <- context_rows$query[!is.na(context_rows$query) & nzchar(context_rows$query)][1]
645 if (!is.na(query_candidate) && nzchar(query_candidate)) {
646 match_one <- regexec("^\\(*contains\\(<([^>]+)>,", query_candidate)
647 matches <- regmatches(query_candidate, match_one)
648 if (length(matches) >= 1 && length(matches[[1]]) >= 2) {
649 within_span_value <- matches[[1]][2]
650 }
651 }
652 }
653
654 new_rows <- collocationScoreQuery(
655 kco,
656 node = node_value,
657 collocate = missing_collocates,
658 vc = vc_value,
659 leftContextSize = left_size,
660 rightContextSize = right_size,
661 ignoreCollocateCase = ignoreCollocateCase,
662 withinSpan = within_span_value,
663 ...
664 )
665
666 if (nrow(new_rows) == 0) {
667 next
668 }
669
670 if (!is.null(required_combinations$vcLabel[i]) && !is.na(required_combinations$vcLabel[i]) && required_combinations$vcLabel[i] != "" && "label" %in% names(new_rows)) {
671 new_rows$label <- required_combinations$vcLabel[i]
672 }
673
674 result <- dplyr::bind_rows(result, new_rows)
675 }
676
677 result
678}
679
Marc Kupietz0a292632025-10-19 14:04:36 +0200680inject_focus_into_query <- function(query) {
681 if (is.null(query) || is.na(query)) {
682 return(query)
683 }
684
685 trimmed <- trimws(query)
686 if (!nzchar(trimmed)) {
687 return(query)
688 }
689
690 if (!grepl("^contains\\(<[^>]+>", trimmed, perl = TRUE)) {
691 return(query)
692 }
693
694 if (grepl("focus\\(", trimmed, perl = TRUE)) {
695 return(query)
696 }
697
698 pattern <- "^contains\\(<([^>]+)>\\s*,\\s*\\((.*)\\)\\)\\s*$"
699 matches <- regexec(pattern, trimmed, perl = TRUE)
700 components <- regmatches(trimmed, matches)
701 if (length(components) == 0 || length(components[[1]]) < 3) {
702 return(query)
703 }
704
705 span <- components[[1]][2]
706 inner <- components[[1]][3]
707 parts <- strsplit(inner, "\\|", perl = TRUE)[[1]]
708 parts <- trimws(parts)
709 parts <- parts[nzchar(parts)]
710
711 if (length(parts) == 0) {
712 return(query)
713 }
714
715 focused <- paste0("focus({", parts, "})")
716 combined <- paste(focused, collapse = " | ")
717
718 sprintf("contains(<%s>, (%s))", span, combined)
719}
720
Marc Kupietz424cb782026-08-31 10:19:29 +0200721add_multi_vc_comparisons <- function(result, missingScoreQuantile = 0.05, verbose = FALSE) {
Marc Kupietz09b1c082026-05-01 14:45:47 +0200722 label <- node <- collocate <- vc <- webUIRequestUrl <- NULL
Marc Kupietzc4540a22025-10-14 17:39:53 +0200723
724 if (!"label" %in% names(result) || dplyr::n_distinct(result$label) < 2) {
725 return(result)
726 }
727
728 numeric_cols <- names(result)[vapply(result, is.numeric, logical(1))]
729 non_score_cols <- c("N", "O", "O1", "O2", "E", "w", "leftContextSize", "rightContextSize", "frequency")
730 score_cols <- setdiff(numeric_cols, non_score_cols)
731
732 if (length(score_cols) == 0) {
733 return(result)
734 }
735
Marc Kupietz9894a372025-10-18 14:51:29 +0200736 compute_score_floor <- function(values) {
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200737 # Estimate a conservative floor so missing scores can be imputed without favoring any label
Marc Kupietz9894a372025-10-18 14:51:29 +0200738 finite_values <- values[is.finite(values)]
739 if (length(finite_values) == 0) {
740 return(0)
741 }
742
743 prob <- min(max(missingScoreQuantile, 0), 0.5)
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200744 # Use a lower quantile as the anchor to stay near the weakest attested scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200745 q_val <- suppressWarnings(stats::quantile(finite_values,
746 probs = prob,
747 names = FALSE,
748 type = 7
749 ))
750
751 if (!is.finite(q_val)) {
752 q_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
753 }
754
755 min_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
756 if (!is.finite(min_val)) {
757 min_val <- 0
758 }
759
760 spread_candidates <- c(
761 suppressWarnings(stats::IQR(finite_values, na.rm = TRUE, type = 7)),
762 stats::sd(finite_values, na.rm = TRUE),
763 abs(q_val) * 0.1,
764 abs(min_val - q_val)
765 )
766 spread_candidates <- spread_candidates[is.finite(spread_candidates)]
767
768 spread <- 0
769 if (length(spread_candidates) > 0) {
770 spread <- max(spread_candidates)
771 }
772 if (!is.finite(spread) || spread == 0) {
773 spread <- max(abs(q_val), abs(min_val), 1e-06)
774 }
775
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200776 # Step away from the anchor by a robust spread estimate to avoid ties with real scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200777 candidate <- q_val - spread
778 if (!is.finite(candidate)) {
779 candidate <- min_val
780 }
781
782 floor_value <- suppressWarnings(min(c(candidate, min_val), na.rm = TRUE))
783 if (!is.finite(floor_value)) {
784 floor_value <- min_val
785 }
786 if (!is.finite(floor_value)) {
787 floor_value <- 0
788 }
789
790 floor_value
791 }
792
793 score_replacements <- stats::setNames(
794 vapply(score_cols, function(col) {
795 compute_score_floor(result[[col]])
796 }, numeric(1)),
797 score_cols
798 )
799
Marc Kupietz7b7a73b2026-08-31 10:31:27 +0200800 # The pivots below keep only the first row per node/collocate/label. Duplicates do occur
801 # legitimately (e.g. the same collocate found at several context positions), but silently
802 # discarding all but one of them would misrepresent the comparison, so say so.
803 comparison_keys <- paste(result$node, result$collocate, result$label, sep = "\r")
804 duplicate_keys <- unique(comparison_keys[duplicated(comparison_keys)])
805 if (length(duplicate_keys) > 0) {
806 warning(
807 sprintf(
808 paste0(
809 "%d node/collocate/label combination(s) occur more than once; only the first row ",
810 "of each is used for the multi-VC comparison columns. Consider ",
811 "mergeDuplicateCollocates() to combine context positions before comparing."
812 ),
813 length(duplicate_keys)
814 ),
815 call. = FALSE
816 )
817 }
818
Marc Kupietzc4540a22025-10-14 17:39:53 +0200819 comparison <- result |>
Marc Kupietz28a29842025-10-18 12:25:09 +0200820 dplyr::select(node, collocate, label, dplyr::all_of(score_cols)) |>
821 tidyr::pivot_wider(
Marc Kupietzc4540a22025-10-14 17:39:53 +0200822 names_from = label,
Marc Kupietz28a29842025-10-18 12:25:09 +0200823 values_from = dplyr::all_of(score_cols),
Marc Kupietzc4540a22025-10-14 17:39:53 +0200824 names_glue = "{.value}_{make.names(label)}",
825 values_fn = dplyr::first
826 )
827
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200828 raw_labels <- unique(result$label)
829 labels <- make.names(raw_labels)
830 label_map <- stats::setNames(raw_labels, labels)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200831 vc_map <- result |>
832 dplyr::select(label, vc) |>
833 dplyr::filter(!is.na(label), label != "") |>
834 dplyr::distinct(label, .keep_all = TRUE)
835 vc_map <- stats::setNames(vc_map$vc, make.names(vc_map$label))
836
837 replace_web_ui_cq <- function(url, vc_value) {
838 if (length(url) == 0 || is.na(url) || url == "") {
839 return(NA_character_)
840 }
841 if (length(vc_value) == 0 || is.na(vc_value)) {
842 vc_value <- ""
843 }
844 encoded_vc <- urltools::url_encode(enc2utf8(as.character(vc_value)))
845 if (grepl("([?&]cq=)[^&]*", url, perl = TRUE)) {
846 return(sub("([?&]cq=)[^&]*", paste0("\\1", encoded_vc), url, perl = TRUE))
847 }
848 if (encoded_vc == "") {
849 return(url)
850 }
851 paste0(url, ifelse(grepl("\\?", url), "&", "?"), "cq=", encoded_vc)
852 }
853
854 if ("webUIRequestUrl" %in% names(result)) {
855 url_data <- result |>
856 dplyr::select(node, collocate, label, webUIRequestUrl) |>
857 tidyr::pivot_wider(
858 names_from = label,
859 values_from = webUIRequestUrl,
860 names_glue = "webUIRequestUrl_{make.names(label)}",
861 values_fn = dplyr::first
862 )
863
864 comparison <- dplyr::left_join(comparison, url_data, by = c("node", "collocate"))
865
866 url_cols <- paste0("webUIRequestUrl_", labels)
867 present_url_cols <- intersect(url_cols, names(comparison))
868 fallback_urls <- vapply(seq_len(nrow(comparison)), function(i) {
869 urls <- unlist(comparison[i, present_url_cols, drop = FALSE], use.names = FALSE)
870 urls <- as.character(urls)
871 urls <- urls[!is.na(urls) & urls != ""]
872 if (length(urls) == 0) {
873 NA_character_
874 } else {
875 urls[1]
876 }
877 }, character(1))
878
879 for (safe_label in labels) {
880 url_col <- paste0("webUIRequestUrl_", safe_label)
881 if (!url_col %in% names(comparison)) {
882 comparison[[url_col]] <- NA_character_
883 }
884 missing_urls <- is.na(comparison[[url_col]]) | comparison[[url_col]] == ""
885 if (any(missing_urls)) {
886 comparison[[url_col]][missing_urls] <- vapply(
887 fallback_urls[missing_urls],
888 replace_web_ui_cq,
889 character(1),
890 vc_value = vc_map[[safe_label]]
891 )
892 }
893 }
894 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200895
Marc Kupietz28a29842025-10-18 12:25:09 +0200896 rank_data <- result |>
897 dplyr::distinct(node, collocate)
898
899 for (i in seq_along(raw_labels)) {
900 raw_lab <- raw_labels[i]
901 safe_lab <- labels[i]
902 label_df <- result[result$label == raw_lab, c("node", "collocate", score_cols), drop = FALSE]
903 if (nrow(label_df) == 0) {
904 next
905 }
906 label_df <- dplyr::distinct(label_df)
907 rank_tbl <- label_df[, c("node", "collocate"), drop = FALSE]
908 for (col in score_cols) {
909 rank_col_name <- paste0("rank_", safe_lab, "_", col)
Marc Kupietz130a2a22025-10-18 16:09:23 +0200910 percentile_col_name <- paste0("percentile_rank_", safe_lab, "_", col)
Marc Kupietz28a29842025-10-18 12:25:09 +0200911 values <- label_df[[col]]
912 ranks <- rep(NA_real_, length(values))
Marc Kupietz130a2a22025-10-18 16:09:23 +0200913 percentiles <- rep(NA_real_, length(values))
Marc Kupietz28a29842025-10-18 12:25:09 +0200914 valid_idx <- which(!is.na(values))
915 if (length(valid_idx) > 0) {
916 ranks[valid_idx] <- rank(-values[valid_idx], ties.method = "first")
Marc Kupietz130a2a22025-10-18 16:09:23 +0200917 total <- length(valid_idx)
918 percentiles[valid_idx] <- 1 - (ranks[valid_idx] - 1) / total
Marc Kupietz28a29842025-10-18 12:25:09 +0200919 }
920 rank_tbl[[rank_col_name]] <- ranks
Marc Kupietz130a2a22025-10-18 16:09:23 +0200921 rank_tbl[[percentile_col_name]] <- percentiles
Marc Kupietz28a29842025-10-18 12:25:09 +0200922 }
923 rank_data <- dplyr::left_join(rank_data, rank_tbl, by = c("node", "collocate"))
924 }
925
926 comparison <- dplyr::left_join(comparison, rank_data, by = c("node", "collocate"))
927
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +0200928 # Record which label/measure cells are absent *before* any imputation happens below.
929 # Deltas computed from imputed cells reflect presence/absence of the collocate in a
930 # virtual corpus, not a measured contrast, so users need to be able to tell them apart.
931 imputed_flags <- lapply(labels, function(safe_label) {
932 label_score_cols <- intersect(paste0(score_cols, "_", safe_label), names(comparison))
933 if (length(label_score_cols) == 0) {
934 return(rep(FALSE, nrow(comparison)))
935 }
936 Reduce(`|`, lapply(label_score_cols, function(col) is.na(comparison[[col]])))
937 })
938 names(imputed_flags) <- paste0("imputed_", labels)
939
Marc Kupietz28a29842025-10-18 12:25:09 +0200940 rank_replacements <- numeric(0)
941 rank_column_names <- grep("^rank_", names(comparison), value = TRUE)
942 if (length(rank_column_names) > 0) {
943 rank_replacements <- stats::setNames(
944 vapply(rank_column_names, function(col) {
945 col_values <- comparison[[col]]
946 valid_values <- col_values[!is.na(col_values)]
947 if (length(valid_values) == 0) {
948 nrow(comparison) + 1
949 } else {
950 suppressWarnings(max(valid_values, na.rm = TRUE)) + 1
951 }
952 }, numeric(1)),
953 rank_column_names
954 )
955 }
956
Marc Kupietz130a2a22025-10-18 16:09:23 +0200957 percentile_replacements <- numeric(0)
958 percentile_column_names <- grep("^percentile_rank_", names(comparison), value = TRUE)
959 if (length(percentile_column_names) > 0) {
960 percentile_replacements <- stats::setNames(
961 rep(0, length(percentile_column_names)),
962 percentile_column_names
963 )
964 }
965
Marc Kupietz28a29842025-10-18 12:25:09 +0200966 collapse_label_values <- function(indices, safe_labels_vec) {
967 if (length(indices) == 0) {
968 return(NA_character_)
969 }
970 labs <- label_map[safe_labels_vec[indices]]
971 fallback <- safe_labels_vec[indices]
972 labs[is.na(labs) | labs == ""] <- fallback[is.na(labs) | labs == ""]
973 labs <- labs[!is.na(labs) & labs != ""]
974 if (length(labs) == 0) {
975 return(NA_character_)
976 }
977 paste(unique(labs), collapse = ", ")
978 }
979
Marc Kupietz09b1c082026-05-01 14:45:47 +0200980 collapse_url_values <- function(indices, url_values) {
981 if (length(indices) == 0 || is.null(url_values)) {
982 return(NA_character_)
983 }
984 urls <- as.character(url_values[indices])
985 urls <- urls[!is.na(urls) & urls != ""]
986 if (length(urls) == 0) {
987 return(NA_character_)
988 }
989 paste(unique(urls), collapse = ", ")
990 }
991
Marc Kupietzc4540a22025-10-14 17:39:53 +0200992 if (length(labels) == 2) {
Marc Kupietz9894a372025-10-18 14:51:29 +0200993 fill_scores <- function(x, y, measure_col) {
994 replacement <- score_replacements[[measure_col]]
995 fallback_min <- suppressWarnings(min(c(x, y), na.rm = TRUE))
996 if (!is.finite(fallback_min)) {
997 fallback_min <- 0
Marc Kupietzc4540a22025-10-14 17:39:53 +0200998 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200999 if (!is.null(replacement) && is.finite(replacement)) {
1000 replacement <- min(replacement, fallback_min)
1001 } else {
1002 replacement <- fallback_min
1003 }
1004 if (!is.finite(replacement)) {
1005 replacement <- 0
1006 }
1007 if (any(is.na(x))) {
1008 x[is.na(x)] <- replacement
1009 }
1010 if (any(is.na(y))) {
1011 y[is.na(y)] <- replacement
1012 }
Marc Kupietzc4540a22025-10-14 17:39:53 +02001013 list(x = x, y = y)
1014 }
1015
Marc Kupietz130a2a22025-10-18 16:09:23 +02001016 fill_percentiles <- function(x, y, left_pct_col, right_pct_col) {
1017 replacement_left <- percentile_replacements[[left_pct_col]]
1018 if (is.null(replacement_left) || !is.finite(replacement_left)) {
1019 replacement_left <- 0
1020 }
1021 replacement_right <- percentile_replacements[[right_pct_col]]
1022 if (is.null(replacement_right) || !is.finite(replacement_right)) {
1023 replacement_right <- 0
1024 }
1025 if (any(is.na(x))) {
1026 x[is.na(x)] <- replacement_left
1027 }
1028 if (any(is.na(y))) {
1029 y[is.na(y)] <- replacement_right
1030 }
1031 list(x = x, y = y)
1032 }
1033
Marc Kupietz28a29842025-10-18 12:25:09 +02001034 fill_ranks <- function(x, y, left_rank_col, right_rank_col) {
1035 fallback <- nrow(comparison) + 1
1036 replacement_left <- rank_replacements[[left_rank_col]]
1037 if (is.null(replacement_left) || !is.finite(replacement_left)) {
1038 replacement_left <- fallback
Marc Kupietzc4540a22025-10-14 17:39:53 +02001039 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001040 replacement_right <- rank_replacements[[right_rank_col]]
1041 if (is.null(replacement_right) || !is.finite(replacement_right)) {
1042 replacement_right <- fallback
1043 }
1044 if (any(is.na(x))) {
1045 x[is.na(x)] <- replacement_left
1046 }
1047 if (any(is.na(y))) {
1048 y[is.na(y)] <- replacement_right
1049 }
Marc Kupietzc4540a22025-10-14 17:39:53 +02001050 list(x = x, y = y)
1051 }
1052
1053 left_label <- labels[1]
1054 right_label <- labels[2]
1055
1056 for (col in score_cols) {
1057 left_col <- paste0(col, "_", left_label)
1058 right_col <- paste0(col, "_", right_label)
1059 if (!all(c(left_col, right_col) %in% names(comparison))) {
1060 next
1061 }
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001062 filled <- fill_scores(comparison[[left_col]], comparison[[right_col]], col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001063 comparison[[left_col]] <- filled$x
1064 comparison[[right_col]] <- filled$y
Marc Kupietzc4540a22025-10-14 17:39:53 +02001065 comparison[[paste0("delta_", col)]] <- filled$x - filled$y
Marc Kupietz28a29842025-10-18 12:25:09 +02001066 rank_left <- paste0("rank_", left_label, "_", col)
1067 rank_right <- paste0("rank_", right_label, "_", col)
1068 if (all(c(rank_left, rank_right) %in% names(comparison))) {
1069 filled_rank <- fill_ranks(
1070 comparison[[rank_left]],
1071 comparison[[rank_right]],
1072 rank_left,
1073 rank_right
1074 )
1075 comparison[[paste0("delta_rank_", col)]] <- filled_rank$x - filled_rank$y
1076 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001077 pct_left <- paste0("percentile_rank_", left_label, "_", col)
1078 pct_right <- paste0("percentile_rank_", right_label, "_", col)
1079 if (all(c(pct_left, pct_right) %in% names(comparison))) {
1080 filled_pct <- fill_percentiles(
1081 comparison[[pct_left]],
1082 comparison[[pct_right]],
1083 pct_left,
1084 pct_right
1085 )
1086 comparison[[paste0("delta_percentile_rank_", col)]] <- filled_pct$x - filled_pct$y
1087 }
Marc Kupietzc4540a22025-10-14 17:39:53 +02001088 }
1089 }
1090
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001091 for (col in score_cols) {
1092 value_cols <- paste0(col, "_", labels)
1093 existing <- value_cols %in% names(comparison)
1094 if (!any(existing)) {
1095 next
1096 }
1097 value_cols <- value_cols[existing]
1098 safe_labels <- labels[existing]
1099
1100 score_values <- comparison[, value_cols, drop = FALSE]
1101
1102 winner_label_col <- paste0("winner_", col)
1103 winner_value_col <- paste0("winner_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001104 winner_url_col <- paste0("winner_", col, "_webUIRequestUrl")
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001105 runner_label_col <- paste0("runner_up_", col)
1106 runner_value_col <- paste0("runner_up_", col, "_value")
Marc Kupietzb2862d42025-10-18 10:17:49 +02001107 loser_label_col <- paste0("loser_", col)
1108 loser_value_col <- paste0("loser_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001109 loser_url_col <- paste0("loser_", col, "_webUIRequestUrl")
Marc Kupietzb2862d42025-10-18 10:17:49 +02001110 max_delta_col <- paste0("max_delta_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001111 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1112 has_urls <- all(url_cols %in% names(comparison))
1113 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001114
1115 if (nrow(score_values) == 0) {
1116 comparison[[winner_label_col]] <- character(0)
1117 comparison[[winner_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001118 if (has_urls) {
1119 comparison[[winner_url_col]] <- character(0)
1120 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001121 comparison[[runner_label_col]] <- character(0)
1122 comparison[[runner_value_col]] <- numeric(0)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001123 comparison[[loser_label_col]] <- character(0)
1124 comparison[[loser_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001125 if (has_urls) {
1126 comparison[[loser_url_col]] <- character(0)
1127 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001128 comparison[[max_delta_col]] <- numeric(0)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001129 next
1130 }
1131
1132 score_matrix <- as.matrix(score_values)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001133 storage.mode(score_matrix) <- "numeric"
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001134
Marc Kupietzb2862d42025-10-18 10:17:49 +02001135 n_rows <- nrow(score_matrix)
1136 winner_labels <- rep(NA_character_, n_rows)
1137 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001138 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001139 runner_labels <- rep(NA_character_, n_rows)
1140 runner_values <- rep(NA_real_, n_rows)
1141 loser_labels <- rep(NA_character_, n_rows)
1142 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001143 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001144 max_deltas <- rep(NA_real_, n_rows)
1145
Marc Kupietzb2862d42025-10-18 10:17:49 +02001146 if (n_rows > 0) {
1147 for (i in seq_len(n_rows)) {
1148 numeric_row <- as.numeric(score_matrix[i, ])
1149 if (all(is.na(numeric_row))) {
1150 next
1151 }
1152
Marc Kupietz9894a372025-10-18 14:51:29 +02001153 replacement <- score_replacements[[col]]
1154 fallback_min <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1155 if (!is.finite(fallback_min)) {
1156 fallback_min <- 0
Marc Kupietzb2862d42025-10-18 10:17:49 +02001157 }
Marc Kupietz9894a372025-10-18 14:51:29 +02001158 if (!is.null(replacement) && is.finite(replacement)) {
1159 replacement <- min(replacement, fallback_min)
1160 } else {
1161 replacement <- fallback_min
1162 }
1163 if (!is.finite(replacement)) {
1164 replacement <- 0
1165 }
1166 if (any(is.na(numeric_row))) {
1167 numeric_row[is.na(numeric_row)] <- replacement
1168 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001169 score_matrix[i, ] <- numeric_row
1170
1171 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1172 max_idx <- which(numeric_row == max_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001173 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001174 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001175 if (has_urls) {
1176 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1177 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001178
1179 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1180 if (length(unique_vals) >= 2) {
1181 runner_val <- unique_vals[2]
1182 runner_idx <- which(numeric_row == runner_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001183 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001184 runner_values[i] <- runner_val
1185 }
1186
1187 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1188 min_idx <- which(numeric_row == min_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001189 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001190 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001191 if (has_urls) {
1192 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1193 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001194
1195 if (is.finite(max_val) && is.finite(min_val)) {
1196 max_deltas[i] <- max_val - min_val
1197 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001198 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001199 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001200
Marc Kupietzb2862d42025-10-18 10:17:49 +02001201 comparison[, value_cols] <- score_matrix
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001202 comparison[[winner_label_col]] <- winner_labels
1203 comparison[[winner_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001204 if (has_urls) {
1205 comparison[[winner_url_col]] <- winner_urls
1206 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001207 comparison[[runner_label_col]] <- runner_labels
1208 comparison[[runner_value_col]] <- runner_values
Marc Kupietzb2862d42025-10-18 10:17:49 +02001209 comparison[[loser_label_col]] <- loser_labels
1210 comparison[[loser_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001211 if (has_urls) {
1212 comparison[[loser_url_col]] <- loser_urls
1213 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001214 comparison[[max_delta_col]] <- max_deltas
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001215 }
1216
Marc Kupietz28a29842025-10-18 12:25:09 +02001217 for (col in score_cols) {
1218 rank_cols <- paste0("rank_", labels, "_", col)
1219 existing <- rank_cols %in% names(comparison)
1220 if (!any(existing)) {
1221 next
1222 }
1223 rank_cols <- rank_cols[existing]
1224 safe_labels <- labels[existing]
1225 rank_values <- comparison[, rank_cols, drop = FALSE]
1226
1227 winner_rank_label_col <- paste0("winner_rank_", col)
1228 winner_rank_value_col <- paste0("winner_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001229 winner_rank_url_col <- paste0("winner_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001230 runner_rank_label_col <- paste0("runner_up_rank_", col)
1231 runner_rank_value_col <- paste0("runner_up_rank_", col, "_value")
1232 loser_rank_label_col <- paste0("loser_rank_", col)
1233 loser_rank_value_col <- paste0("loser_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001234 loser_rank_url_col <- paste0("loser_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001235 max_delta_rank_col <- paste0("max_delta_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001236 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1237 has_urls <- all(url_cols %in% names(comparison))
1238 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz28a29842025-10-18 12:25:09 +02001239
1240 if (nrow(rank_values) == 0) {
1241 comparison[[winner_rank_label_col]] <- character(0)
1242 comparison[[winner_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001243 if (has_urls) {
1244 comparison[[winner_rank_url_col]] <- character(0)
1245 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001246 comparison[[runner_rank_label_col]] <- character(0)
1247 comparison[[runner_rank_value_col]] <- numeric(0)
1248 comparison[[loser_rank_label_col]] <- character(0)
1249 comparison[[loser_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001250 if (has_urls) {
1251 comparison[[loser_rank_url_col]] <- character(0)
1252 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001253 comparison[[max_delta_rank_col]] <- numeric(0)
1254 next
1255 }
1256
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001257 rank_matrix <- as.matrix(rank_values)
1258 storage.mode(rank_matrix) <- "numeric"
Marc Kupietz28a29842025-10-18 12:25:09 +02001259
1260 n_rows <- nrow(rank_matrix)
1261 winner_labels <- rep(NA_character_, n_rows)
1262 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001263 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001264 runner_labels <- rep(NA_character_, n_rows)
1265 runner_values <- rep(NA_real_, n_rows)
1266 loser_labels <- rep(NA_character_, n_rows)
1267 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001268 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001269 max_deltas <- rep(NA_real_, n_rows)
1270
1271 for (i in seq_len(n_rows)) {
1272 numeric_row <- as.numeric(rank_matrix[i, ])
1273 if (all(is.na(numeric_row))) {
1274 next
1275 }
1276
1277 if (length(rank_cols) > 0) {
1278 replacement_vec <- rank_replacements[rank_cols]
1279 replacement_vec[is.na(replacement_vec)] <- nrow(comparison) + 1
1280 missing_idx <- which(is.na(numeric_row))
1281 if (length(missing_idx) > 0) {
1282 numeric_row[missing_idx] <- replacement_vec[missing_idx]
1283 }
1284 }
1285
1286 valid_idx <- seq_along(numeric_row)
1287 valid_values <- numeric_row[valid_idx]
1288 min_val <- suppressWarnings(min(valid_values, na.rm = TRUE))
1289 min_positions <- valid_idx[which(valid_values == min_val)]
1290 winner_labels[i] <- collapse_label_values(min_positions, safe_labels)
1291 winner_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001292 if (has_urls) {
1293 winner_urls[i] <- collapse_url_values(min_positions, url_values[i, ])
1294 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001295
1296 ordered_vals <- sort(unique(valid_values), decreasing = FALSE)
1297 if (length(ordered_vals) >= 2) {
1298 runner_val <- ordered_vals[2]
1299 runner_positions <- valid_idx[which(valid_values == runner_val)]
1300 runner_labels[i] <- collapse_label_values(runner_positions, safe_labels)
1301 runner_values[i] <- runner_val
1302 }
1303
1304 max_val <- suppressWarnings(max(valid_values, na.rm = TRUE))
1305 max_positions <- valid_idx[which(valid_values == max_val)]
1306 loser_labels[i] <- collapse_label_values(max_positions, safe_labels)
1307 loser_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001308 if (has_urls) {
1309 loser_urls[i] <- collapse_url_values(max_positions, url_values[i, ])
1310 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001311
1312 if (is.finite(max_val) && is.finite(min_val)) {
1313 max_deltas[i] <- max_val - min_val
1314 }
1315 }
1316
1317 comparison[[winner_rank_label_col]] <- winner_labels
1318 comparison[[winner_rank_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001319 if (has_urls) {
1320 comparison[[winner_rank_url_col]] <- winner_urls
1321 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001322 comparison[[runner_rank_label_col]] <- runner_labels
1323 comparison[[runner_rank_value_col]] <- runner_values
1324 comparison[[loser_rank_label_col]] <- loser_labels
1325 comparison[[loser_rank_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001326 if (has_urls) {
1327 comparison[[loser_rank_url_col]] <- loser_urls
1328 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001329 comparison[[max_delta_rank_col]] <- max_deltas
1330 }
1331
Marc Kupietz130a2a22025-10-18 16:09:23 +02001332 for (col in score_cols) {
1333 pct_cols <- paste0("percentile_rank_", labels, "_", col)
1334 existing <- pct_cols %in% names(comparison)
1335 if (!any(existing)) {
1336 next
1337 }
1338 pct_cols <- pct_cols[existing]
1339 safe_labels <- labels[existing]
1340 pct_values <- comparison[, pct_cols, drop = FALSE]
1341
1342 winner_pct_label_col <- paste0("winner_percentile_rank_", col)
1343 winner_pct_value_col <- paste0("winner_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001344 winner_pct_url_col <- paste0("winner_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001345 runner_pct_label_col <- paste0("runner_up_percentile_rank_", col)
1346 runner_pct_value_col <- paste0("runner_up_percentile_rank_", col, "_value")
1347 loser_pct_label_col <- paste0("loser_percentile_rank_", col)
1348 loser_pct_value_col <- paste0("loser_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001349 loser_pct_url_col <- paste0("loser_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001350 max_delta_pct_col <- paste0("max_delta_percentile_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001351 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1352 has_urls <- all(url_cols %in% names(comparison))
1353 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz130a2a22025-10-18 16:09:23 +02001354
1355 if (nrow(pct_values) == 0) {
1356 comparison[[winner_pct_label_col]] <- character(0)
1357 comparison[[winner_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001358 if (has_urls) {
1359 comparison[[winner_pct_url_col]] <- character(0)
1360 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001361 comparison[[runner_pct_label_col]] <- character(0)
1362 comparison[[runner_pct_value_col]] <- numeric(0)
1363 comparison[[loser_pct_label_col]] <- character(0)
1364 comparison[[loser_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001365 if (has_urls) {
1366 comparison[[loser_pct_url_col]] <- character(0)
1367 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001368 comparison[[max_delta_pct_col]] <- numeric(0)
1369 next
1370 }
1371
1372 pct_matrix <- as.matrix(pct_values)
1373 storage.mode(pct_matrix) <- "numeric"
1374
1375 n_rows <- nrow(pct_matrix)
1376 winner_labels <- rep(NA_character_, n_rows)
1377 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001378 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001379 runner_labels <- rep(NA_character_, n_rows)
1380 runner_values <- rep(NA_real_, n_rows)
1381 loser_labels <- rep(NA_character_, n_rows)
1382 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001383 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001384 max_deltas <- rep(NA_real_, n_rows)
1385
1386 if (n_rows > 0) {
1387 for (i in seq_len(n_rows)) {
1388 numeric_row <- as.numeric(pct_matrix[i, ])
1389 if (all(is.na(numeric_row))) {
1390 next
1391 }
1392
1393 if (any(is.na(numeric_row))) {
1394 numeric_row[is.na(numeric_row)] <- 0
1395 }
1396 pct_matrix[i, ] <- numeric_row
1397
1398 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1399 max_idx <- which(numeric_row == max_val)
1400 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
1401 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001402 if (has_urls) {
1403 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1404 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001405
1406 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1407 if (length(unique_vals) >= 2) {
1408 runner_val <- unique_vals[2]
1409 runner_idx <- which(numeric_row == runner_val)
1410 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
1411 runner_values[i] <- runner_val
1412 }
1413
1414 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1415 min_idx <- which(numeric_row == min_val)
1416 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
1417 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001418 if (has_urls) {
1419 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1420 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001421
1422 if (is.finite(max_val) && is.finite(min_val)) {
1423 max_deltas[i] <- max_val - min_val
1424 }
1425 }
1426 }
1427
1428 comparison[, pct_cols] <- pct_matrix
1429 comparison[[winner_pct_label_col]] <- winner_labels
1430 comparison[[winner_pct_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001431 if (has_urls) {
1432 comparison[[winner_pct_url_col]] <- winner_urls
1433 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001434 comparison[[runner_pct_label_col]] <- runner_labels
1435 comparison[[runner_pct_value_col]] <- runner_values
1436 comparison[[loser_pct_label_col]] <- loser_labels
1437 comparison[[loser_pct_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001438 if (has_urls) {
1439 comparison[[loser_pct_url_col]] <- loser_urls
1440 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001441 comparison[[max_delta_pct_col]] <- max_deltas
1442 }
1443
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +02001444 for (flag_col in names(imputed_flags)) {
1445 comparison[[flag_col]] <- imputed_flags[[flag_col]]
1446 }
1447 if (length(imputed_flags) > 0) {
1448 comparison$n_imputed <- as.integer(Reduce(`+`, lapply(imputed_flags, as.integer)))
1449 } else {
1450 comparison$n_imputed <- rep(0L, nrow(comparison))
1451 }
1452 comparison$imputed <- comparison$n_imputed > 0L
1453
Marc Kupietz424cb782026-08-31 10:19:29 +02001454 n_imputed_rows <- sum(comparison$imputed)
1455 if (n_imputed_rows > 0) {
1456 log_info(verbose, sprintf(
1457 paste0(
1458 "Imputed scores for %d of %d node/collocate combinations (%d of %d label cells) ",
1459 "that are not attested in every virtual corpus. Their delta and winner/loser ",
1460 "columns reflect presence vs. absence rather than a measured contrast; see the ",
1461 "`imputed` column and `queryMissingScores`.\n"
1462 ),
1463 n_imputed_rows,
1464 nrow(comparison),
1465 sum(comparison$n_imputed),
1466 nrow(comparison) * length(labels)
1467 ))
1468 }
1469
Marc Kupietz09b1c082026-05-01 14:45:47 +02001470 collapse_consensus_url_columns <- function(url_cols) {
1471 if (length(url_cols) == 0) {
1472 return(rep(NA_character_, nrow(comparison)))
1473 }
1474 vapply(seq_len(nrow(comparison)), function(i) {
1475 urls <- unlist(comparison[i, url_cols, drop = FALSE], use.names = FALSE)
1476 urls <- as.character(urls)
1477 urls <- urls[!is.na(urls) & urls != ""]
1478 urls <- unique(urls)
1479 if (length(urls) == 1) {
1480 urls
1481 } else {
1482 NA_character_
1483 }
1484 }, character(1))
1485 }
1486
1487 winner_score_url_cols <- intersect(paste0("winner_", score_cols, "_webUIRequestUrl"), names(comparison))
1488 loser_score_url_cols <- intersect(paste0("loser_", score_cols, "_webUIRequestUrl"), names(comparison))
1489 if (length(winner_score_url_cols) > 0) {
1490 comparison$winner_webUIRequestUrl <- collapse_consensus_url_columns(winner_score_url_cols)
1491 }
1492 if (length(loser_score_url_cols) > 0) {
1493 comparison$loser_webUIRequestUrl <- collapse_consensus_url_columns(loser_score_url_cols)
1494 }
1495
1496 url_helper_cols <- intersect(paste0("webUIRequestUrl_", labels), names(comparison))
1497 if (length(url_helper_cols) > 0) {
1498 comparison <- dplyr::select(comparison, -dplyr::all_of(url_helper_cols))
1499 }
1500
Marc Kupietzc4540a22025-10-14 17:39:53 +02001501 dplyr::left_join(result, comparison, by = c("node", "collocate"))
1502}
1503
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001504#' @importFrom magrittr debug_pipe
Marc Kupietz2b17b212023-08-27 17:47:26 +02001505#' @importFrom stringr str_detect
1506#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1507#'
1508matches2FreqTable <- function(matches,
1509 index = 0,
1510 minOccur = 5,
1511 leftContextSize = 5,
1512 rightContextSize = 5,
1513 ignoreCollocateCase = FALSE,
1514 stopwords = c(),
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001515 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz2b17b212023-08-27 17:47:26 +02001516 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1517 verbose = TRUE) {
1518 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1519 frequency <- NULL
1520
1521 if (nrow(matches) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001522 dplyr::tibble(word = c(), frequency = c())
Marc Kupietz2b17b212023-08-27 17:47:26 +02001523 } else if (index == 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001524 if (!"tokens" %in% colnames(matches) || !is.list(matches$tokens)) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001525 log_info(verbose, "Outdated KorAP server: Falling back to client side tokenization.\n")
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001526 return(snippet2FreqTable(matches$snippet, minOccur, leftContextSize, rightContextSize,
1527 ignoreCollocateCase = ignoreCollocateCase,
1528 stopwords = stopwords, oldTable = oldTable, verbose = verbose
1529 ))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001530 }
1531 log_info(verbose, paste("Joining", nrow(matches), "kwics\n"))
Marc Kupietza25fbd92025-10-14 17:38:09 +02001532 for (i in seq_len(nrow(matches))) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001533 oldTable <- matches2FreqTable(
1534 matches,
1535 i,
1536 leftContextSize = leftContextSize,
1537 rightContextSize = rightContextSize,
1538 collocateFilterRegex = collocateFilterRegex,
1539 oldTable = oldTable,
1540 stopwords = stopwords
1541 )
1542 }
1543 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001544 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001545 group_by(word) |>
1546 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001547 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001548 arrange(desc(frequency))
1549 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001550 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietz2b17b212023-08-27 17:47:26 +02001551
1552 left <- tail(unlist(matches$tokens$left[index]), leftContextSize)
1553
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001554 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001555
1556 right <- head(unlist(matches$tokens$right[index]), rightContextSize)
1557
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001558 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001559
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001560 if (length(left) + length(right) == 0) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001561 oldTable
1562 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001563 table(c(left, right)) |>
1564 dplyr::as_tibble(.name_repair = "minimal") |>
1565 dplyr::rename(word = 1, frequency = 2) |>
1566 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001567 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001568 dplyr::bind_rows(oldTable)
1569 }
1570 }
1571}
1572
1573#' @importFrom magrittr debug_pipe
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001574#' @importFrom stringr str_match str_split str_detect
1575#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1576#'
1577snippet2FreqTable <- function(snippet,
1578 minOccur = 5,
1579 leftContextSize = 5,
1580 rightContextSize = 5,
1581 ignoreCollocateCase = FALSE,
1582 stopwords = c(),
1583 tokenizeRegex = "([! )(\uc2\uab,.:?\u201e\u201c\'\"]+|&quot;)",
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001584 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001585 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1586 verbose = TRUE) {
1587 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1588 frequency <- NULL
1589
1590 if (length(snippet) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001591 dplyr::tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001592 } else if (length(snippet) > 1) {
Marc Kupietza47d1502023-04-18 15:26:47 +02001593 log_info(verbose, paste("Joining", length(snippet), "kwics\n"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001594 for (s in snippet) {
1595 oldTable <- snippet2FreqTable(
1596 s,
1597 leftContextSize = leftContextSize,
1598 rightContextSize = rightContextSize,
Marc Kupietz47d0d2b2021-12-19 16:38:52 +01001599 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001600 oldTable = oldTable,
1601 stopwords = stopwords
1602 )
1603 }
Marc Kupietza47d1502023-04-18 15:26:47 +02001604 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001605 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001606 group_by(word) |>
1607 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001608 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001609 arrange(desc(frequency))
1610 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001611 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001612 match <-
1613 str_match(
1614 snippet,
1615 '<span class="context-left">(<span class="more"></span>)?(.*[^ ]) *</span><span class="match"><mark>.*</mark></span><span class="context-right"> *([^<]*)'
1616 )
1617
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001618 left <- if (leftContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001619 tail(unlist(str_split(match[1, 3], tokenizeRegex)), leftContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001620 } else {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001621 ""
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001622 }
1623 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001624
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001625 right <- if (rightContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001626 head(unlist(str_split(match[1, 4], tokenizeRegex)), rightContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001627 } else {
1628 ""
1629 }
1630 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001631
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001632 if (is.na(left[1]) || is.na(right[1]) || length(left) + length(right) == 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001633 oldTable
1634 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001635 table(c(left, right)) |>
1636 dplyr::as_tibble(.name_repair = "minimal") |>
1637 dplyr::rename(word = 1, frequency = 2) |>
1638 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001639 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001640 dplyr::bind_rows(oldTable)
1641 }
1642 }
1643}
1644
1645#' Preliminary synsemantic stopwords function
1646#'
1647#' @description
Marc Kupietz67edcb52021-09-20 21:54:24 +02001648#' `r lifecycle::badge("experimental")`
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001649#'
1650#' Preliminary synsemantic stopwords function to be used in collocation analysis.
1651#'
1652#' @details
1653#' Currently only suitable for German. See stopwords package for other languages.
1654#'
1655#' @param ... future arguments for language detection
1656#'
1657#' @family collocation analysis functions
1658#' @return Vector of synsemantic stopwords.
1659#' @export
1660synsemanticStopwords <- function(...) {
Marc Kupietzc79155b2025-10-19 13:42:55 +02001661 base <- c(
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001662 "der",
1663 "die",
1664 "und",
1665 "in",
1666 "den",
1667 "von",
1668 "mit",
1669 "das",
1670 "zu",
1671 "im",
1672 "ist",
1673 "auf",
1674 "sich",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001675 "des",
1676 "dem",
1677 "nicht",
1678 "ein",
1679 "eine",
1680 "es",
1681 "auch",
1682 "an",
1683 "als",
1684 "am",
1685 "aus",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001686 "bei",
1687 "er",
1688 "dass",
1689 "sie",
1690 "nach",
1691 "um",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001692 "zum",
1693 "noch",
1694 "war",
1695 "einen",
1696 "einer",
1697 "wie",
1698 "einem",
1699 "vor",
1700 "bis",
1701 "\u00fcber",
1702 "so",
1703 "aber",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001704 "diese",
Marc Kupietzc79155b2025-10-19 13:42:55 +02001705 "oder"
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001706 )
Marc Kupietzc79155b2025-10-19 13:42:55 +02001707
1708 lower <- unique(tolower(base))
1709 capitalized <- paste0(toupper(substr(lower, 1, 1)), substring(lower, 2))
1710
1711 unique(c(lower, capitalized))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001712}
1713
Marc Kupietz5a336b62021-11-27 17:51:35 +01001714
Marc Kupietz76b05592021-12-19 16:26:15 +01001715# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +01001716findExample <-
1717 function(kco,
1718 query,
1719 vc = "",
1720 matchOnly = TRUE) {
1721 out <- character(length = length(query))
1722
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001723 if (length(vc) < length(query)) {
Marc Kupietz5a336b62021-11-27 17:51:35 +01001724 vc <- rep(vc, length(query))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001725 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001726
1727 for (i in seq_along(query)) {
1728 q <- corpusQuery(kco, paste0("(", query[i], ")"), vc = vc[i], metadataOnly = FALSE)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001729 if (q@totalResults > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001730 q <- fetchNext(q, maxFetch = 50, randomizePageOrder = F)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001731 example <- as.character((q@collectedMatches)$snippet[1])
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001732 out[i] <- if (matchOnly) {
1733 gsub(".*<mark>(.+)</mark>.*", "\\1", example)
Marc Kupietz5a336b62021-11-27 17:51:35 +01001734 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001735 stringr::str_replace(example, "<[^>]*>", "")
Marc Kupietz5a336b62021-11-27 17:51:35 +01001736 }
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001737 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001738 out[i] <- ""
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001739 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001740 }
1741 out
1742 }
1743
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001744collocatesQuery <-
1745 function(kco,
1746 query,
1747 vc = "",
1748 minOccur = 5,
1749 leftContextSize = 5,
1750 rightContextSize = 5,
1751 searchHitsSampleLimit = 20000,
1752 ignoreCollocateCase = FALSE,
1753 stopwords = c(),
Marc Kupietzb2862d42025-10-18 10:17:49 +02001754 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001755 ...) {
1756 frequency <- NULL
1757 q <- corpusQuery(kco, query, vc, metadataOnly = F, ...)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001758 if (q@totalResults == 0) {
1759 tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001760 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001761 q <- fetchNext(q, maxFetch = searchHitsSampleLimit, randomizePageOrder = TRUE)
1762 matches2FreqTable(q@collectedMatches,
1763 0,
1764 minOccur = minOccur,
1765 leftContextSize = leftContextSize,
1766 rightContextSize = rightContextSize,
1767 ignoreCollocateCase = ignoreCollocateCase,
1768 stopwords = stopwords,
Marc Kupietzb2862d42025-10-18 10:17:49 +02001769 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001770 ...,
1771 verbose = kco@verbose
1772 ) |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001773 mutate(frequency = frequency * q@totalResults / min(q@totalResults, searchHitsSampleLimit)) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001774 filter(frequency >= minOccur)
1775 }
1776 }