blob: b5b1ded0df5823d35864a49a669bb60f515884a0 [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
4#' Collocation analysis
5#'
Marc Kupietza8c40f42025-06-24 15:49:52 +02006#' @family collocation analysis functions
Marc Kupietzdbd431a2021-08-29 12:17:45 +02007#' @aliases collocationAnalysis
8#'
9#' @description
Marc Kupietzdbd431a2021-08-29 12:17:45 +020010#'
11#' Performs a collocation analysis for the given node (or query)
12#' in the given virtual corpus.
13#'
14#' @details
15#' The collocation analysis is currently implemented on the client side, as some of the
16#' functionality is not yet provided by the KorAP backend. Mainly for this reason
17#' it is very slow (several minutes, up to hours), but on the other hand very flexible.
18#' You can, for example, perform the analysis in arbitrary virtual corpora, use complex node queries,
19#' and look for expression-internal collocates using the focus function (see examples and demo).
20#'
21#' To increase speed at the cost of accuracy and possible false negatives,
22#' you can decrease searchHitsSampleLimit and/or topCollocatesLimit and/or set exactFrequencies to FALSE.
23#'
Marc Kupietze7f0d682025-02-19 10:50:59 +010024#' Note that some outdated non-DeReKo back-ends might not yet support returning tokenized matches (warning issued).
25#' In this case, the client library will fall back to client-side tokenization which might be slightly less accurate.
26#' 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 +020027#' user interface.
28#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +020029#'
Marc Kupietz67edcb52021-09-20 21:54:24 +020030#' @param lemmatizeNodeQuery if TRUE, node query will be lemmatized, i.e. `x -> [tt/l=x]`
Marc Kupietzdbd431a2021-08-29 12:17:45 +020031#' @param minOccur minimum absolute number of observed co-occurrences to consider a collocate candidate
32#' @param topCollocatesLimit limit analysis to the n most frequent collocates in the search hits sample
33#' @param searchHitsSampleLimit limit the size of the search hits sample
34#' @param stopwords vector of stopwords not to be considered as collocates
Marc Kupietz6bd9cad2024-12-18 15:57:26 +010035#' @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 +020036#' @param exactFrequencies if FALSE, extrapolate observed co-occurrence frequencies from frequencies in search hits sample, otherwise retrieve exact co-occurrence frequencies
37#' @param seed seed for random page collecting order
Marc Kupietz67edcb52021-09-20 21:54:24 +020038#' @param expand if TRUE, `node` and `vc` parameters are expanded to all of their combinations
Marc Kupietz7d400e02021-12-19 16:39:36 +010039#' @param maxRecurse apply collocation analysis recursively `maxRecurse` times
40#' @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 +020041#' @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})
42#' @param threshold minimum value of `thresholdScore` function call to apply collocation analysis recursively (only applied when \code{maxRecurse > 0})
Marc Kupietz7d400e02021-12-19 16:39:36 +010043#' @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 +010044#' @param collocateFilterRegex allow only collocates matching the regular expression
Marc Kupietzde679ea2025-10-19 13:14:51 +020045#' @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 +020046#' @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 +020047#' @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 +020048#' @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 Kupietz67edcb52021-09-20 21:54:24 +020049#' @param ... more arguments will be passed to [collocationScoreQuery()]
Marc Kupietzdbd431a2021-08-29 12:17:45 +020050#' @inheritParams collocationScoreQuery,KorAPConnection-method
Marc Kupietz130a2a22025-10-18 16:09:23 +020051#' @return
52#' A tibble where each row represents a candidate collocate for the requested node.
53#' Columns include (depending on the selected association measures):
Marc Kupietzdb2fabd2026-04-27 15:01:37 +020054#'
Marc Kupietz130a2a22025-10-18 16:09:23 +020055#' \itemize{
56#' \item \code{node}, \code{collocate}, \code{vc}, \code{label}: identifiers for the query node, collocate, virtual corpus, and optional label.
57#' \item Frequency and contingency information such as \code{frequency}, \code{O}, \code{O1}, \code{O2}, \code{E}, \code{leftContextSize}, \code{rightContextSize}, and \code{w}.
58#' \item Association measures (e.g. \code{logDice}, \code{ll}, \code{mi}, ...), one column per requested scorer.
59#' \item Per-labelled association scores produced by multi-VC comparisons using the pattern \code{<measure>_<label>}.
60#' \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>}.
61#' \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 +020062#' \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 +020063#' \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 +020064#' \item Optional helper columns such as \code{query}, \code{example}, or \code{url} when example retrieval is requested.
65#' }
Marc Kupietz95253342026-08-31 10:18:43 +020066#' @section Interpreting multi-VC comparisons:
67#'
68#' The comparison columns are an exploration aid, not a significance test. When
69#' reading them, keep three properties in mind.
70#'
71#' \strong{Imputed scores describe presence/absence, not contrast.} A collocate
72#' that passes the `minOccur` and `topCollocatesLimit` thresholds in one virtual
73#' corpus but not in another has no observed score for the latter. Such cells are
74#' imputed from a floor derived from the pooled result set (see
75#' `missingScoreQuantile`), so the corresponding `delta_*` and `max_delta_*`
76#' values measure the distance to that floor rather than an attested difference.
77#' The `imputed`, `n_imputed` and `imputed_<label>` columns mark these rows;
78#' `dplyr::filter(!imputed)` restricts the result to collocates attested
79#' everywhere, and `queryMissingScores = TRUE` replaces most imputed cells with
80#' scores actually retrieved from the backend.
81#'
82#' \strong{Imputed values are relative to one analysis.} The floor is computed
83#' from the scores present in the result at hand. Analysing a node on its own and
84#' analysing it together with other nodes therefore yield different imputed
85#' values, and deltas involving imputed cells are not comparable across separate
86#' calls. Deltas between observed scores are unaffected.
87#'
88#' \strong{Winners carry no uncertainty.} Unlike [ci()], which attaches
89#' confidence intervals to relative frequencies, the `winner_*` / `loser_*`
90#' columns simply order point estimates. A collocate wins by a hair on six
91#' occurrences exactly as decisively as one that wins by a wide margin on
92#' thousands. Consult the observed frequencies (`O`, `O1`, `O2`) and the
93#' `webUIRequestUrl` concordance links before drawing conclusions from a
94#' small difference.
95#'
96#' Note also that `rank_<label>_<measure>` and
97#' `percentile_rank_<label>_<measure>` are computed within each label, over that
98#' label's own candidate set. Candidate sets usually differ in size between
99#' virtual corpora, so rank-based deltas compare positions in populations of
100#' different sizes.
Marc Kupietzc4540a22025-10-14 17:39:53 +0200101#' @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 +0200102#' @importFrom purrr pmap
Marc Kupietzc4540a22025-10-14 17:39:53 +0200103#' @importFrom tidyr expand_grid pivot_wider
104#' @importFrom rlang sym
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200105#'
106#' @examples
Marc Kupietz6ae76052021-09-21 10:34:00 +0200107#' \dontrun{
108#'
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200109#' # Find top collocates of "Packung" inside and outside the sports domain.
110#' KorAPConnection(verbose = TRUE) |>
111#' collocationAnalysis("Packung",
112#' vc = c("textClass=sport", "textClass!=sport"),
113#' leftContextSize = 1, rightContextSize = 1, topCollocatesLimit = 20
114#' ) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200115#' dplyr::filter(logDice >= 5)
116#' }
117#'
Marc Kupietz6ae76052021-09-21 10:34:00 +0200118#' \dontrun{
119#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200120#' # Identify the most prominent light verb construction with "in ... setzen".
121#' # Note that, currently, the use of focus function disallows exactFrequencies.
Marc Kupietz4cd066d2025-02-28 15:48:23 +0100122#' KorAPConnection(verbose = TRUE) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200123#' collocationAnalysis("focus(in [tt/p=NN] {[tt/l=setzen]})",
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200124#' leftContextSize = 1, rightContextSize = 0, exactFrequencies = FALSE, topCollocatesLimit = 20
125#' )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200126#' }
127#'
128#' @export
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200129setMethod(
130 "collocationAnalysis", "KorAPConnection",
131 function(kco,
132 node,
133 vc = "",
134 lemmatizeNodeQuery = FALSE,
135 minOccur = 5,
136 leftContextSize = 5,
137 rightContextSize = 5,
138 topCollocatesLimit = 200,
139 searchHitsSampleLimit = 20000,
140 ignoreCollocateCase = FALSE,
141 withinSpan = ifelse(exactFrequencies, "base/s=s", ""),
142 exactFrequencies = TRUE,
143 stopwords = append(RKorAPClient::synsemanticStopwords(), node),
144 seed = 7,
145 expand = length(vc) != length(node),
146 maxRecurse = 0,
147 addExamples = FALSE,
148 thresholdScore = "logDice",
149 threshold = 2.0,
150 localStopwords = c(),
151 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzde679ea2025-10-19 13:14:51 +0200152 queryMissingScores = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200153 missingScoreQuantile = 0.05,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200154 vcLabel = NA_character_,
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200155 cacheAs = NULL,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200156 ...) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200157 word <- frequency <- O <- NULL
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200158
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200159 if (!is.null(cacheAs) && !grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
160 cacheAs <- paste0(cacheAs, ".rds")
161 }
162
163 if (!is.null(cacheAs) && file.exists(cacheAs)) {
164 log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
165 return(readRDS(cacheAs))
166 }
167
Marc Kupietzb2862d42025-10-18 10:17:49 +0200168 if (!exactFrequencies && (!is.na(withinSpan) && !is.null(withinSpan) && nzchar(withinSpan))) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200169 stop(sprintf("Not empty withinSpan (='%s') requires exactFrequencies=TRUE", withinSpan), call. = FALSE)
170 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200171
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200172 warnIfNotAuthorized(kco)
Marc Kupietz581a29b2021-09-04 20:51:04 +0200173
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200174 if (lemmatizeNodeQuery) {
175 node <- lemmatizeWordQuery(node)
176 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200177
Marc Kupietze34a8be2025-10-17 20:13:42 +0200178 vcNames <- names(vc)
Marc Kupietze34a8be2025-10-17 20:13:42 +0200179 if (is.null(vcNames)) {
180 vcNames <- rep(NA_character_, length(vc))
Marc Kupietze34a8be2025-10-17 20:13:42 +0200181 }
182
183 label_lookup <- NULL
Marc Kupietzb2862d42025-10-18 10:17:49 +0200184 if (!is.null(names(vc)) && length(vc) > 0) {
185 raw_names <- names(vc)
186 if (any(!is.na(raw_names) & raw_names != "")) {
187 label_lookup <- stats::setNames(raw_names, vc)
188 }
Marc Kupietze34a8be2025-10-17 20:13:42 +0200189 }
190
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200191 result <- if (length(node) > 1 || length(vc) > 1) {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200192 grid <- if (expand) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200193 tmp_grid <- tidyr::expand_grid(node = node, idx = seq_along(vc))
194 tmp_grid$vc <- vc[tmp_grid$idx]
195 tmp_grid$vcLabel <- vcNames[tmp_grid$idx]
196 tmp_grid[, c("node", "vc", "vcLabel"), drop = FALSE]
Marc Kupietze34a8be2025-10-17 20:13:42 +0200197 } else {
198 tibble(node = node, vc = vc, vcLabel = vcNames)
199 }
200
201 multi_result <- purrr::pmap(grid, function(node, vc, vcLabel, ...) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200202 collocationAnalysis(kco,
203 node = node,
204 vc = vc,
205 minOccur = minOccur,
206 leftContextSize = leftContextSize,
207 rightContextSize = rightContextSize,
208 topCollocatesLimit = topCollocatesLimit,
209 searchHitsSampleLimit = searchHitsSampleLimit,
210 ignoreCollocateCase = ignoreCollocateCase,
211 withinSpan = withinSpan,
212 exactFrequencies = exactFrequencies,
213 stopwords = stopwords,
214 addExamples = TRUE,
215 localStopwords = localStopwords,
216 seed = seed,
217 expand = expand,
Marc Kupietz9894a372025-10-18 14:51:29 +0200218 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200219 queryMissingScores = queryMissingScores,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200220 collocateFilterRegex = collocateFilterRegex,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200221 vcLabel = vcLabel,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200222 ...
223 )
224 }) |>
Marc Kupietze31322e2025-10-17 18:55:36 +0200225 bind_rows()
226
227 if (!"vc" %in% names(multi_result) || nrow(multi_result) == 0) {
228 multi_result
229 } else {
Marc Kupietzde679ea2025-10-19 13:14:51 +0200230 if (queryMissingScores) {
231 multi_result <- backfill_missing_scores(
232 multi_result,
233 grid = grid,
234 kco = kco,
235 ignoreCollocateCase = ignoreCollocateCase,
236 ...
237 )
238 }
239
Marc Kupietze34a8be2025-10-17 20:13:42 +0200240 if (!"label" %in% names(multi_result)) {
241 multi_result$label <- NA_character_
242 }
243
244 if (!is.null(label_lookup)) {
245 override <- unname(label_lookup[multi_result$vc])
246 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
247 if (any(missing_idx)) {
248 multi_result$label[missing_idx] <- override[missing_idx]
249 }
250 }
251
252 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
253 if (any(missing_idx)) {
254 multi_result$label[missing_idx] <- queryStringToLabel(multi_result$vc[missing_idx])
255 }
256
Marc Kupietze31322e2025-10-17 18:55:36 +0200257 multi_result |>
Marc Kupietz9894a372025-10-18 14:51:29 +0200258 add_multi_vc_comparisons(
Marc Kupietz9894a372025-10-18 14:51:29 +0200259 missingScoreQuantile = missingScoreQuantile
260 )
Marc Kupietze31322e2025-10-17 18:55:36 +0200261 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200262 } else {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200263 if ((is.na(vcLabel) || vcLabel == "") && length(vcNames) >= 1) {
264 vcLabel <- vcNames[1]
265 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200266
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200267 set.seed(seed)
268 candidates <- collocatesQuery(
269 kco,
270 node,
271 vc = vc,
272 minOccur = minOccur,
273 leftContextSize = leftContextSize,
274 rightContextSize = rightContextSize,
275 searchHitsSampleLimit = searchHitsSampleLimit,
276 ignoreCollocateCase = ignoreCollocateCase,
277 stopwords = append(stopwords, localStopwords),
Marc Kupietzb2862d42025-10-18 10:17:49 +0200278 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200279 ...
280 )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200281
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200282 if (nrow(candidates) > 0) {
283 candidates <- candidates |>
284 filter(frequency >= minOccur) |>
285 slice_head(n = topCollocatesLimit)
286 collocationScoreQuery(
287 kco,
288 node = node,
289 collocate = candidates$word,
290 vc = vc,
291 leftContextSize = leftContextSize,
292 rightContextSize = rightContextSize,
293 observed = if (exactFrequencies) NA else candidates$frequency,
294 ignoreCollocateCase = ignoreCollocateCase,
295 withinSpan = withinSpan,
296 ...
297 ) |>
298 filter(O >= minOccur) |>
299 dplyr::arrange(dplyr::desc(logDice))
300 } else {
301 tibble()
302 }
303 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200304
305 if (!is.na(vcLabel) && vcLabel != "" && "label" %in% names(result)) {
306 result$label <- rep(vcLabel, nrow(result))
307 }
308
309 threshold_col <- thresholdScore
310 if (maxRecurse > 0 && nrow(result) > 0 && threshold_col %in% names(result)) {
311 threshold_values <- result[[threshold_col]]
312 eligible_idx <- which(!is.na(threshold_values) & threshold_values >= threshold)
313 if (length(eligible_idx) > 0) {
314 recurseWith <- result[eligible_idx, , drop = FALSE]
315 result <- collocationAnalysis(
316 kco,
317 node = paste0("(", buildCollocationQuery(
318 removeWithinSpan(recurseWith$node, withinSpan),
319 recurseWith$collocate,
320 leftContextSize = leftContextSize,
321 rightContextSize = rightContextSize,
322 withinSpan = ""
323 ), ")"),
324 vc = vc,
325 minOccur = minOccur,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200326 leftContextSize = leftContextSize,
327 rightContextSize = rightContextSize,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200328 withinSpan = withinSpan,
329 maxRecurse = maxRecurse - 1,
330 stopwords = stopwords,
331 localStopwords = recurseWith$collocate,
332 exactFrequencies = exactFrequencies,
333 searchHitsSampleLimit = searchHitsSampleLimit,
334 topCollocatesLimit = topCollocatesLimit,
335 addExamples = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200336 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200337 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200338 queryMissingScores = queryMissingScores,
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200339 thresholdScore = thresholdScore,
340 threshold = threshold,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200341 vcLabel = vcLabel
342 ) |>
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200343 bind_rows(result)
344
345 if (threshold_col %in% names(result)) {
346 threshold_values <- result[[threshold_col]]
347 keep_idx <- is.na(threshold_values) | threshold_values >= threshold
348 result <- result[keep_idx, , drop = FALSE]
349 }
350
351 result <- result |>
Marc Kupietzb2862d42025-10-18 10:17:49 +0200352 filter(O >= minOccur) |>
353 dplyr::arrange(dplyr::desc(logDice))
354 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200355 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200356
357 if (addExamples && nrow(result) > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200358 result$query <- buildCollocationQuery(
359 result$node,
360 result$collocate,
361 leftContextSize = leftContextSize,
362 rightContextSize = rightContextSize,
363 withinSpan = withinSpan
364 )
365 result$example <- findExample(
366 kco,
367 query = result$query,
368 vc = result$vc
369 )
370 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200371
Marc Kupietz0a292632025-10-19 14:04:36 +0200372 if (!is.null(withinSpan) && !is.na(withinSpan) && nzchar(withinSpan) &&
373 nrow(result) > 0 &&
374 "webUIRequestUrl" %in% names(result) &&
375 "query" %in% names(result)) {
376 candidate_rows <- which(!is.na(result$node) &
377 !grepl("focus\\(", result$node, perl = TRUE) &
378 !is.na(result$query) & nzchar(result$query))
379
380 if (length(candidate_rows) > 0) {
381 focused_queries <- vapply(
382 result$query[candidate_rows],
383 inject_focus_into_query,
384 character(1)
385 )
386
387 changed <- focused_queries != result$query[candidate_rows]
388 if (any(changed)) {
389 indices <- candidate_rows[changed]
390 vc_values <- as.character(result$vc)
391 vc_values[is.na(vc_values)] <- ""
392
393 result$webUIRequestUrl[indices] <- mapply(
394 function(new_query, vc_value) {
395 buildWebUIRequestUrlFromString(
396 kco@KorAPUrl,
397 new_query,
398 vc = vc_value,
399 ql = "poliqarp"
400 )
401 },
402 focused_queries[changed],
403 vc_values[indices],
404 USE.NAMES = FALSE
405 )
406 }
407 }
408 }
409
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200410 if (!is.null(cacheAs)) {
411 log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", cacheAs))
412 saveRDS(result, cacheAs)
413 }
414
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200415 result
416 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200417)
418
Marc Kupietz76b05592021-12-19 16:26:15 +0100419# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +0100420removeWithinSpan <- function(query, withinSpan) {
421 if (withinSpan == "") {
422 return(query)
423 }
424 needle <- sprintf("^\\(contains\\(<%s>, ?(.*)\\){2}$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200425 res <- gsub(needle, "\\1", query)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100426 needle <- sprintf("^contains\\(<%s>, ?(.*)\\)$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200427 res <- gsub(needle, "\\1", res)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100428 return(res)
429}
430
Marc Kupietzde679ea2025-10-19 13:14:51 +0200431backfill_missing_scores <- function(result,
432 grid,
433 kco,
434 ignoreCollocateCase,
435 ...) {
436 if (!"vc" %in% names(result) || !"node" %in% names(result) || !"collocate" %in% names(result)) {
437 return(result)
438 }
439
440 if (nrow(result) == 0) {
441 return(result)
442 }
443
Marc Kupietz9c53e412026-06-21 12:13:44 +0200444 distinct_pairs <- dplyr::distinct(
445 result,
446 .data$node,
447 .data$collocate
448 )
Marc Kupietzde679ea2025-10-19 13:14:51 +0200449 if (nrow(distinct_pairs) == 0) {
450 return(result)
451 }
452
453 collocates_by_node <- split(as.character(distinct_pairs$collocate), distinct_pairs$node)
454 if (length(collocates_by_node) == 0) {
455 return(result)
456 }
457
458 required_combinations <- unique(as.data.frame(grid[, c("node", "vc", "vcLabel")], drop = FALSE))
459 for (i in seq_len(nrow(required_combinations))) {
460 node_value <- required_combinations$node[i]
461 vc_value <- required_combinations$vc[i]
462
463 collocate_pool <- collocates_by_node[[node_value]]
464 if (is.null(collocate_pool) || length(collocate_pool) == 0) {
465 next
466 }
467
468 existing_idx <- result$node == node_value & result$vc == vc_value
469 existing_collocates <- unique(as.character(result$collocate[existing_idx]))
470 missing_collocates <- setdiff(unique(collocate_pool), existing_collocates)
471 missing_collocates <- missing_collocates[!is.na(missing_collocates) & nzchar(missing_collocates)]
472
473 if (length(missing_collocates) == 0) {
474 next
475 }
476
477 context_rows <- result[result$node == node_value & result$vc == vc_value, , drop = FALSE]
478 if (nrow(context_rows) == 0) {
479 context_rows <- result[result$node == node_value, , drop = FALSE]
480 }
481
482 left_size <- context_rows$leftContextSize[!is.na(context_rows$leftContextSize)][1]
483 if (is.na(left_size) || length(left_size) == 0) {
484 left_size <- result$leftContextSize[!is.na(result$leftContextSize)][1]
485 }
486 if (is.na(left_size) || length(left_size) == 0) {
487 left_size <- 5
488 }
489
490 right_size <- context_rows$rightContextSize[!is.na(context_rows$rightContextSize)][1]
491 if (is.na(right_size) || length(right_size) == 0) {
492 right_size <- result$rightContextSize[!is.na(result$rightContextSize)][1]
493 }
494 if (is.na(right_size) || length(right_size) == 0) {
495 right_size <- 5
496 }
497
498 within_span_value <- ""
499 if ("query" %in% names(context_rows)) {
500 query_candidate <- context_rows$query[!is.na(context_rows$query) & nzchar(context_rows$query)][1]
501 if (!is.na(query_candidate) && nzchar(query_candidate)) {
502 match_one <- regexec("^\\(*contains\\(<([^>]+)>,", query_candidate)
503 matches <- regmatches(query_candidate, match_one)
504 if (length(matches) >= 1 && length(matches[[1]]) >= 2) {
505 within_span_value <- matches[[1]][2]
506 }
507 }
508 }
509
510 new_rows <- collocationScoreQuery(
511 kco,
512 node = node_value,
513 collocate = missing_collocates,
514 vc = vc_value,
515 leftContextSize = left_size,
516 rightContextSize = right_size,
517 ignoreCollocateCase = ignoreCollocateCase,
518 withinSpan = within_span_value,
519 ...
520 )
521
522 if (nrow(new_rows) == 0) {
523 next
524 }
525
526 if (!is.null(required_combinations$vcLabel[i]) && !is.na(required_combinations$vcLabel[i]) && required_combinations$vcLabel[i] != "" && "label" %in% names(new_rows)) {
527 new_rows$label <- required_combinations$vcLabel[i]
528 }
529
530 result <- dplyr::bind_rows(result, new_rows)
531 }
532
533 result
534}
535
Marc Kupietz0a292632025-10-19 14:04:36 +0200536inject_focus_into_query <- function(query) {
537 if (is.null(query) || is.na(query)) {
538 return(query)
539 }
540
541 trimmed <- trimws(query)
542 if (!nzchar(trimmed)) {
543 return(query)
544 }
545
546 if (!grepl("^contains\\(<[^>]+>", trimmed, perl = TRUE)) {
547 return(query)
548 }
549
550 if (grepl("focus\\(", trimmed, perl = TRUE)) {
551 return(query)
552 }
553
554 pattern <- "^contains\\(<([^>]+)>\\s*,\\s*\\((.*)\\)\\)\\s*$"
555 matches <- regexec(pattern, trimmed, perl = TRUE)
556 components <- regmatches(trimmed, matches)
557 if (length(components) == 0 || length(components[[1]]) < 3) {
558 return(query)
559 }
560
561 span <- components[[1]][2]
562 inner <- components[[1]][3]
563 parts <- strsplit(inner, "\\|", perl = TRUE)[[1]]
564 parts <- trimws(parts)
565 parts <- parts[nzchar(parts)]
566
567 if (length(parts) == 0) {
568 return(query)
569 }
570
571 focused <- paste0("focus({", parts, "})")
572 combined <- paste(focused, collapse = " | ")
573
574 sprintf("contains(<%s>, (%s))", span, combined)
575}
576
Marc Kupietz77852b22025-10-19 11:35:34 +0200577add_multi_vc_comparisons <- function(result, missingScoreQuantile = 0.05) {
Marc Kupietz09b1c082026-05-01 14:45:47 +0200578 label <- node <- collocate <- vc <- webUIRequestUrl <- NULL
Marc Kupietzc4540a22025-10-14 17:39:53 +0200579
580 if (!"label" %in% names(result) || dplyr::n_distinct(result$label) < 2) {
581 return(result)
582 }
583
584 numeric_cols <- names(result)[vapply(result, is.numeric, logical(1))]
585 non_score_cols <- c("N", "O", "O1", "O2", "E", "w", "leftContextSize", "rightContextSize", "frequency")
586 score_cols <- setdiff(numeric_cols, non_score_cols)
587
588 if (length(score_cols) == 0) {
589 return(result)
590 }
591
Marc Kupietz9894a372025-10-18 14:51:29 +0200592 compute_score_floor <- function(values) {
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200593 # Estimate a conservative floor so missing scores can be imputed without favoring any label
Marc Kupietz9894a372025-10-18 14:51:29 +0200594 finite_values <- values[is.finite(values)]
595 if (length(finite_values) == 0) {
596 return(0)
597 }
598
599 prob <- min(max(missingScoreQuantile, 0), 0.5)
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200600 # Use a lower quantile as the anchor to stay near the weakest attested scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200601 q_val <- suppressWarnings(stats::quantile(finite_values,
602 probs = prob,
603 names = FALSE,
604 type = 7
605 ))
606
607 if (!is.finite(q_val)) {
608 q_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
609 }
610
611 min_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
612 if (!is.finite(min_val)) {
613 min_val <- 0
614 }
615
616 spread_candidates <- c(
617 suppressWarnings(stats::IQR(finite_values, na.rm = TRUE, type = 7)),
618 stats::sd(finite_values, na.rm = TRUE),
619 abs(q_val) * 0.1,
620 abs(min_val - q_val)
621 )
622 spread_candidates <- spread_candidates[is.finite(spread_candidates)]
623
624 spread <- 0
625 if (length(spread_candidates) > 0) {
626 spread <- max(spread_candidates)
627 }
628 if (!is.finite(spread) || spread == 0) {
629 spread <- max(abs(q_val), abs(min_val), 1e-06)
630 }
631
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200632 # Step away from the anchor by a robust spread estimate to avoid ties with real scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200633 candidate <- q_val - spread
634 if (!is.finite(candidate)) {
635 candidate <- min_val
636 }
637
638 floor_value <- suppressWarnings(min(c(candidate, min_val), na.rm = TRUE))
639 if (!is.finite(floor_value)) {
640 floor_value <- min_val
641 }
642 if (!is.finite(floor_value)) {
643 floor_value <- 0
644 }
645
646 floor_value
647 }
648
649 score_replacements <- stats::setNames(
650 vapply(score_cols, function(col) {
651 compute_score_floor(result[[col]])
652 }, numeric(1)),
653 score_cols
654 )
655
Marc Kupietzc4540a22025-10-14 17:39:53 +0200656 comparison <- result |>
Marc Kupietz28a29842025-10-18 12:25:09 +0200657 dplyr::select(node, collocate, label, dplyr::all_of(score_cols)) |>
658 tidyr::pivot_wider(
Marc Kupietzc4540a22025-10-14 17:39:53 +0200659 names_from = label,
Marc Kupietz28a29842025-10-18 12:25:09 +0200660 values_from = dplyr::all_of(score_cols),
Marc Kupietzc4540a22025-10-14 17:39:53 +0200661 names_glue = "{.value}_{make.names(label)}",
662 values_fn = dplyr::first
663 )
664
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200665 raw_labels <- unique(result$label)
666 labels <- make.names(raw_labels)
667 label_map <- stats::setNames(raw_labels, labels)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200668 vc_map <- result |>
669 dplyr::select(label, vc) |>
670 dplyr::filter(!is.na(label), label != "") |>
671 dplyr::distinct(label, .keep_all = TRUE)
672 vc_map <- stats::setNames(vc_map$vc, make.names(vc_map$label))
673
674 replace_web_ui_cq <- function(url, vc_value) {
675 if (length(url) == 0 || is.na(url) || url == "") {
676 return(NA_character_)
677 }
678 if (length(vc_value) == 0 || is.na(vc_value)) {
679 vc_value <- ""
680 }
681 encoded_vc <- urltools::url_encode(enc2utf8(as.character(vc_value)))
682 if (grepl("([?&]cq=)[^&]*", url, perl = TRUE)) {
683 return(sub("([?&]cq=)[^&]*", paste0("\\1", encoded_vc), url, perl = TRUE))
684 }
685 if (encoded_vc == "") {
686 return(url)
687 }
688 paste0(url, ifelse(grepl("\\?", url), "&", "?"), "cq=", encoded_vc)
689 }
690
691 if ("webUIRequestUrl" %in% names(result)) {
692 url_data <- result |>
693 dplyr::select(node, collocate, label, webUIRequestUrl) |>
694 tidyr::pivot_wider(
695 names_from = label,
696 values_from = webUIRequestUrl,
697 names_glue = "webUIRequestUrl_{make.names(label)}",
698 values_fn = dplyr::first
699 )
700
701 comparison <- dplyr::left_join(comparison, url_data, by = c("node", "collocate"))
702
703 url_cols <- paste0("webUIRequestUrl_", labels)
704 present_url_cols <- intersect(url_cols, names(comparison))
705 fallback_urls <- vapply(seq_len(nrow(comparison)), function(i) {
706 urls <- unlist(comparison[i, present_url_cols, drop = FALSE], use.names = FALSE)
707 urls <- as.character(urls)
708 urls <- urls[!is.na(urls) & urls != ""]
709 if (length(urls) == 0) {
710 NA_character_
711 } else {
712 urls[1]
713 }
714 }, character(1))
715
716 for (safe_label in labels) {
717 url_col <- paste0("webUIRequestUrl_", safe_label)
718 if (!url_col %in% names(comparison)) {
719 comparison[[url_col]] <- NA_character_
720 }
721 missing_urls <- is.na(comparison[[url_col]]) | comparison[[url_col]] == ""
722 if (any(missing_urls)) {
723 comparison[[url_col]][missing_urls] <- vapply(
724 fallback_urls[missing_urls],
725 replace_web_ui_cq,
726 character(1),
727 vc_value = vc_map[[safe_label]]
728 )
729 }
730 }
731 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200732
Marc Kupietz28a29842025-10-18 12:25:09 +0200733 rank_data <- result |>
734 dplyr::distinct(node, collocate)
735
736 for (i in seq_along(raw_labels)) {
737 raw_lab <- raw_labels[i]
738 safe_lab <- labels[i]
739 label_df <- result[result$label == raw_lab, c("node", "collocate", score_cols), drop = FALSE]
740 if (nrow(label_df) == 0) {
741 next
742 }
743 label_df <- dplyr::distinct(label_df)
744 rank_tbl <- label_df[, c("node", "collocate"), drop = FALSE]
745 for (col in score_cols) {
746 rank_col_name <- paste0("rank_", safe_lab, "_", col)
Marc Kupietz130a2a22025-10-18 16:09:23 +0200747 percentile_col_name <- paste0("percentile_rank_", safe_lab, "_", col)
Marc Kupietz28a29842025-10-18 12:25:09 +0200748 values <- label_df[[col]]
749 ranks <- rep(NA_real_, length(values))
Marc Kupietz130a2a22025-10-18 16:09:23 +0200750 percentiles <- rep(NA_real_, length(values))
Marc Kupietz28a29842025-10-18 12:25:09 +0200751 valid_idx <- which(!is.na(values))
752 if (length(valid_idx) > 0) {
753 ranks[valid_idx] <- rank(-values[valid_idx], ties.method = "first")
Marc Kupietz130a2a22025-10-18 16:09:23 +0200754 total <- length(valid_idx)
755 percentiles[valid_idx] <- 1 - (ranks[valid_idx] - 1) / total
Marc Kupietz28a29842025-10-18 12:25:09 +0200756 }
757 rank_tbl[[rank_col_name]] <- ranks
Marc Kupietz130a2a22025-10-18 16:09:23 +0200758 rank_tbl[[percentile_col_name]] <- percentiles
Marc Kupietz28a29842025-10-18 12:25:09 +0200759 }
760 rank_data <- dplyr::left_join(rank_data, rank_tbl, by = c("node", "collocate"))
761 }
762
763 comparison <- dplyr::left_join(comparison, rank_data, by = c("node", "collocate"))
764
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +0200765 # Record which label/measure cells are absent *before* any imputation happens below.
766 # Deltas computed from imputed cells reflect presence/absence of the collocate in a
767 # virtual corpus, not a measured contrast, so users need to be able to tell them apart.
768 imputed_flags <- lapply(labels, function(safe_label) {
769 label_score_cols <- intersect(paste0(score_cols, "_", safe_label), names(comparison))
770 if (length(label_score_cols) == 0) {
771 return(rep(FALSE, nrow(comparison)))
772 }
773 Reduce(`|`, lapply(label_score_cols, function(col) is.na(comparison[[col]])))
774 })
775 names(imputed_flags) <- paste0("imputed_", labels)
776
Marc Kupietz28a29842025-10-18 12:25:09 +0200777 rank_replacements <- numeric(0)
778 rank_column_names <- grep("^rank_", names(comparison), value = TRUE)
779 if (length(rank_column_names) > 0) {
780 rank_replacements <- stats::setNames(
781 vapply(rank_column_names, function(col) {
782 col_values <- comparison[[col]]
783 valid_values <- col_values[!is.na(col_values)]
784 if (length(valid_values) == 0) {
785 nrow(comparison) + 1
786 } else {
787 suppressWarnings(max(valid_values, na.rm = TRUE)) + 1
788 }
789 }, numeric(1)),
790 rank_column_names
791 )
792 }
793
Marc Kupietz130a2a22025-10-18 16:09:23 +0200794 percentile_replacements <- numeric(0)
795 percentile_column_names <- grep("^percentile_rank_", names(comparison), value = TRUE)
796 if (length(percentile_column_names) > 0) {
797 percentile_replacements <- stats::setNames(
798 rep(0, length(percentile_column_names)),
799 percentile_column_names
800 )
801 }
802
Marc Kupietz28a29842025-10-18 12:25:09 +0200803 collapse_label_values <- function(indices, safe_labels_vec) {
804 if (length(indices) == 0) {
805 return(NA_character_)
806 }
807 labs <- label_map[safe_labels_vec[indices]]
808 fallback <- safe_labels_vec[indices]
809 labs[is.na(labs) | labs == ""] <- fallback[is.na(labs) | labs == ""]
810 labs <- labs[!is.na(labs) & labs != ""]
811 if (length(labs) == 0) {
812 return(NA_character_)
813 }
814 paste(unique(labs), collapse = ", ")
815 }
816
Marc Kupietz09b1c082026-05-01 14:45:47 +0200817 collapse_url_values <- function(indices, url_values) {
818 if (length(indices) == 0 || is.null(url_values)) {
819 return(NA_character_)
820 }
821 urls <- as.character(url_values[indices])
822 urls <- urls[!is.na(urls) & urls != ""]
823 if (length(urls) == 0) {
824 return(NA_character_)
825 }
826 paste(unique(urls), collapse = ", ")
827 }
828
Marc Kupietzc4540a22025-10-14 17:39:53 +0200829 if (length(labels) == 2) {
Marc Kupietz9894a372025-10-18 14:51:29 +0200830 fill_scores <- function(x, y, measure_col) {
831 replacement <- score_replacements[[measure_col]]
832 fallback_min <- suppressWarnings(min(c(x, y), na.rm = TRUE))
833 if (!is.finite(fallback_min)) {
834 fallback_min <- 0
Marc Kupietzc4540a22025-10-14 17:39:53 +0200835 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200836 if (!is.null(replacement) && is.finite(replacement)) {
837 replacement <- min(replacement, fallback_min)
838 } else {
839 replacement <- fallback_min
840 }
841 if (!is.finite(replacement)) {
842 replacement <- 0
843 }
844 if (any(is.na(x))) {
845 x[is.na(x)] <- replacement
846 }
847 if (any(is.na(y))) {
848 y[is.na(y)] <- replacement
849 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200850 list(x = x, y = y)
851 }
852
Marc Kupietz130a2a22025-10-18 16:09:23 +0200853 fill_percentiles <- function(x, y, left_pct_col, right_pct_col) {
854 replacement_left <- percentile_replacements[[left_pct_col]]
855 if (is.null(replacement_left) || !is.finite(replacement_left)) {
856 replacement_left <- 0
857 }
858 replacement_right <- percentile_replacements[[right_pct_col]]
859 if (is.null(replacement_right) || !is.finite(replacement_right)) {
860 replacement_right <- 0
861 }
862 if (any(is.na(x))) {
863 x[is.na(x)] <- replacement_left
864 }
865 if (any(is.na(y))) {
866 y[is.na(y)] <- replacement_right
867 }
868 list(x = x, y = y)
869 }
870
Marc Kupietz28a29842025-10-18 12:25:09 +0200871 fill_ranks <- function(x, y, left_rank_col, right_rank_col) {
872 fallback <- nrow(comparison) + 1
873 replacement_left <- rank_replacements[[left_rank_col]]
874 if (is.null(replacement_left) || !is.finite(replacement_left)) {
875 replacement_left <- fallback
Marc Kupietzc4540a22025-10-14 17:39:53 +0200876 }
Marc Kupietz28a29842025-10-18 12:25:09 +0200877 replacement_right <- rank_replacements[[right_rank_col]]
878 if (is.null(replacement_right) || !is.finite(replacement_right)) {
879 replacement_right <- fallback
880 }
881 if (any(is.na(x))) {
882 x[is.na(x)] <- replacement_left
883 }
884 if (any(is.na(y))) {
885 y[is.na(y)] <- replacement_right
886 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200887 list(x = x, y = y)
888 }
889
890 left_label <- labels[1]
891 right_label <- labels[2]
892
893 for (col in score_cols) {
894 left_col <- paste0(col, "_", left_label)
895 right_col <- paste0(col, "_", right_label)
896 if (!all(c(left_col, right_col) %in% names(comparison))) {
897 next
898 }
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200899 filled <- fill_scores(comparison[[left_col]], comparison[[right_col]], col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200900 comparison[[left_col]] <- filled$x
901 comparison[[right_col]] <- filled$y
Marc Kupietzc4540a22025-10-14 17:39:53 +0200902 comparison[[paste0("delta_", col)]] <- filled$x - filled$y
Marc Kupietz28a29842025-10-18 12:25:09 +0200903 rank_left <- paste0("rank_", left_label, "_", col)
904 rank_right <- paste0("rank_", right_label, "_", col)
905 if (all(c(rank_left, rank_right) %in% names(comparison))) {
906 filled_rank <- fill_ranks(
907 comparison[[rank_left]],
908 comparison[[rank_right]],
909 rank_left,
910 rank_right
911 )
912 comparison[[paste0("delta_rank_", col)]] <- filled_rank$x - filled_rank$y
913 }
Marc Kupietz130a2a22025-10-18 16:09:23 +0200914 pct_left <- paste0("percentile_rank_", left_label, "_", col)
915 pct_right <- paste0("percentile_rank_", right_label, "_", col)
916 if (all(c(pct_left, pct_right) %in% names(comparison))) {
917 filled_pct <- fill_percentiles(
918 comparison[[pct_left]],
919 comparison[[pct_right]],
920 pct_left,
921 pct_right
922 )
923 comparison[[paste0("delta_percentile_rank_", col)]] <- filled_pct$x - filled_pct$y
924 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200925 }
926 }
927
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200928 for (col in score_cols) {
929 value_cols <- paste0(col, "_", labels)
930 existing <- value_cols %in% names(comparison)
931 if (!any(existing)) {
932 next
933 }
934 value_cols <- value_cols[existing]
935 safe_labels <- labels[existing]
936
937 score_values <- comparison[, value_cols, drop = FALSE]
938
939 winner_label_col <- paste0("winner_", col)
940 winner_value_col <- paste0("winner_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +0200941 winner_url_col <- paste0("winner_", col, "_webUIRequestUrl")
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200942 runner_label_col <- paste0("runner_up_", col)
943 runner_value_col <- paste0("runner_up_", col, "_value")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200944 loser_label_col <- paste0("loser_", col)
945 loser_value_col <- paste0("loser_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +0200946 loser_url_col <- paste0("loser_", col, "_webUIRequestUrl")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200947 max_delta_col <- paste0("max_delta_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200948 url_cols <- paste0("webUIRequestUrl_", safe_labels)
949 has_urls <- all(url_cols %in% names(comparison))
950 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200951
952 if (nrow(score_values) == 0) {
953 comparison[[winner_label_col]] <- character(0)
954 comparison[[winner_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200955 if (has_urls) {
956 comparison[[winner_url_col]] <- character(0)
957 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200958 comparison[[runner_label_col]] <- character(0)
959 comparison[[runner_value_col]] <- numeric(0)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200960 comparison[[loser_label_col]] <- character(0)
961 comparison[[loser_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200962 if (has_urls) {
963 comparison[[loser_url_col]] <- character(0)
964 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200965 comparison[[max_delta_col]] <- numeric(0)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200966 next
967 }
968
969 score_matrix <- as.matrix(score_values)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200970 storage.mode(score_matrix) <- "numeric"
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200971
Marc Kupietzb2862d42025-10-18 10:17:49 +0200972 n_rows <- nrow(score_matrix)
973 winner_labels <- rep(NA_character_, n_rows)
974 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200975 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200976 runner_labels <- rep(NA_character_, n_rows)
977 runner_values <- rep(NA_real_, n_rows)
978 loser_labels <- rep(NA_character_, n_rows)
979 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200980 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200981 max_deltas <- rep(NA_real_, n_rows)
982
Marc Kupietzb2862d42025-10-18 10:17:49 +0200983 if (n_rows > 0) {
984 for (i in seq_len(n_rows)) {
985 numeric_row <- as.numeric(score_matrix[i, ])
986 if (all(is.na(numeric_row))) {
987 next
988 }
989
Marc Kupietz9894a372025-10-18 14:51:29 +0200990 replacement <- score_replacements[[col]]
991 fallback_min <- suppressWarnings(min(numeric_row, na.rm = TRUE))
992 if (!is.finite(fallback_min)) {
993 fallback_min <- 0
Marc Kupietzb2862d42025-10-18 10:17:49 +0200994 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200995 if (!is.null(replacement) && is.finite(replacement)) {
996 replacement <- min(replacement, fallback_min)
997 } else {
998 replacement <- fallback_min
999 }
1000 if (!is.finite(replacement)) {
1001 replacement <- 0
1002 }
1003 if (any(is.na(numeric_row))) {
1004 numeric_row[is.na(numeric_row)] <- replacement
1005 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001006 score_matrix[i, ] <- numeric_row
1007
1008 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1009 max_idx <- which(numeric_row == max_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001010 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001011 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001012 if (has_urls) {
1013 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1014 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001015
1016 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1017 if (length(unique_vals) >= 2) {
1018 runner_val <- unique_vals[2]
1019 runner_idx <- which(numeric_row == runner_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001020 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001021 runner_values[i] <- runner_val
1022 }
1023
1024 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1025 min_idx <- which(numeric_row == min_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001026 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001027 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001028 if (has_urls) {
1029 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1030 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001031
1032 if (is.finite(max_val) && is.finite(min_val)) {
1033 max_deltas[i] <- max_val - min_val
1034 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001035 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001036 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001037
Marc Kupietzb2862d42025-10-18 10:17:49 +02001038 comparison[, value_cols] <- score_matrix
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001039 comparison[[winner_label_col]] <- winner_labels
1040 comparison[[winner_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001041 if (has_urls) {
1042 comparison[[winner_url_col]] <- winner_urls
1043 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001044 comparison[[runner_label_col]] <- runner_labels
1045 comparison[[runner_value_col]] <- runner_values
Marc Kupietzb2862d42025-10-18 10:17:49 +02001046 comparison[[loser_label_col]] <- loser_labels
1047 comparison[[loser_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001048 if (has_urls) {
1049 comparison[[loser_url_col]] <- loser_urls
1050 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001051 comparison[[max_delta_col]] <- max_deltas
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001052 }
1053
Marc Kupietz28a29842025-10-18 12:25:09 +02001054 for (col in score_cols) {
1055 rank_cols <- paste0("rank_", labels, "_", col)
1056 existing <- rank_cols %in% names(comparison)
1057 if (!any(existing)) {
1058 next
1059 }
1060 rank_cols <- rank_cols[existing]
1061 safe_labels <- labels[existing]
1062 rank_values <- comparison[, rank_cols, drop = FALSE]
1063
1064 winner_rank_label_col <- paste0("winner_rank_", col)
1065 winner_rank_value_col <- paste0("winner_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001066 winner_rank_url_col <- paste0("winner_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001067 runner_rank_label_col <- paste0("runner_up_rank_", col)
1068 runner_rank_value_col <- paste0("runner_up_rank_", col, "_value")
1069 loser_rank_label_col <- paste0("loser_rank_", col)
1070 loser_rank_value_col <- paste0("loser_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001071 loser_rank_url_col <- paste0("loser_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001072 max_delta_rank_col <- paste0("max_delta_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001073 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1074 has_urls <- all(url_cols %in% names(comparison))
1075 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz28a29842025-10-18 12:25:09 +02001076
1077 if (nrow(rank_values) == 0) {
1078 comparison[[winner_rank_label_col]] <- character(0)
1079 comparison[[winner_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001080 if (has_urls) {
1081 comparison[[winner_rank_url_col]] <- character(0)
1082 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001083 comparison[[runner_rank_label_col]] <- character(0)
1084 comparison[[runner_rank_value_col]] <- numeric(0)
1085 comparison[[loser_rank_label_col]] <- character(0)
1086 comparison[[loser_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001087 if (has_urls) {
1088 comparison[[loser_rank_url_col]] <- character(0)
1089 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001090 comparison[[max_delta_rank_col]] <- numeric(0)
1091 next
1092 }
1093
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001094 rank_matrix <- as.matrix(rank_values)
1095 storage.mode(rank_matrix) <- "numeric"
Marc Kupietz28a29842025-10-18 12:25:09 +02001096
1097 n_rows <- nrow(rank_matrix)
1098 winner_labels <- rep(NA_character_, n_rows)
1099 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001100 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001101 runner_labels <- rep(NA_character_, n_rows)
1102 runner_values <- rep(NA_real_, n_rows)
1103 loser_labels <- rep(NA_character_, n_rows)
1104 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001105 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001106 max_deltas <- rep(NA_real_, n_rows)
1107
1108 for (i in seq_len(n_rows)) {
1109 numeric_row <- as.numeric(rank_matrix[i, ])
1110 if (all(is.na(numeric_row))) {
1111 next
1112 }
1113
1114 if (length(rank_cols) > 0) {
1115 replacement_vec <- rank_replacements[rank_cols]
1116 replacement_vec[is.na(replacement_vec)] <- nrow(comparison) + 1
1117 missing_idx <- which(is.na(numeric_row))
1118 if (length(missing_idx) > 0) {
1119 numeric_row[missing_idx] <- replacement_vec[missing_idx]
1120 }
1121 }
1122
1123 valid_idx <- seq_along(numeric_row)
1124 valid_values <- numeric_row[valid_idx]
1125 min_val <- suppressWarnings(min(valid_values, na.rm = TRUE))
1126 min_positions <- valid_idx[which(valid_values == min_val)]
1127 winner_labels[i] <- collapse_label_values(min_positions, safe_labels)
1128 winner_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001129 if (has_urls) {
1130 winner_urls[i] <- collapse_url_values(min_positions, url_values[i, ])
1131 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001132
1133 ordered_vals <- sort(unique(valid_values), decreasing = FALSE)
1134 if (length(ordered_vals) >= 2) {
1135 runner_val <- ordered_vals[2]
1136 runner_positions <- valid_idx[which(valid_values == runner_val)]
1137 runner_labels[i] <- collapse_label_values(runner_positions, safe_labels)
1138 runner_values[i] <- runner_val
1139 }
1140
1141 max_val <- suppressWarnings(max(valid_values, na.rm = TRUE))
1142 max_positions <- valid_idx[which(valid_values == max_val)]
1143 loser_labels[i] <- collapse_label_values(max_positions, safe_labels)
1144 loser_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001145 if (has_urls) {
1146 loser_urls[i] <- collapse_url_values(max_positions, url_values[i, ])
1147 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001148
1149 if (is.finite(max_val) && is.finite(min_val)) {
1150 max_deltas[i] <- max_val - min_val
1151 }
1152 }
1153
1154 comparison[[winner_rank_label_col]] <- winner_labels
1155 comparison[[winner_rank_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001156 if (has_urls) {
1157 comparison[[winner_rank_url_col]] <- winner_urls
1158 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001159 comparison[[runner_rank_label_col]] <- runner_labels
1160 comparison[[runner_rank_value_col]] <- runner_values
1161 comparison[[loser_rank_label_col]] <- loser_labels
1162 comparison[[loser_rank_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001163 if (has_urls) {
1164 comparison[[loser_rank_url_col]] <- loser_urls
1165 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001166 comparison[[max_delta_rank_col]] <- max_deltas
1167 }
1168
Marc Kupietz130a2a22025-10-18 16:09:23 +02001169 for (col in score_cols) {
1170 pct_cols <- paste0("percentile_rank_", labels, "_", col)
1171 existing <- pct_cols %in% names(comparison)
1172 if (!any(existing)) {
1173 next
1174 }
1175 pct_cols <- pct_cols[existing]
1176 safe_labels <- labels[existing]
1177 pct_values <- comparison[, pct_cols, drop = FALSE]
1178
1179 winner_pct_label_col <- paste0("winner_percentile_rank_", col)
1180 winner_pct_value_col <- paste0("winner_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001181 winner_pct_url_col <- paste0("winner_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001182 runner_pct_label_col <- paste0("runner_up_percentile_rank_", col)
1183 runner_pct_value_col <- paste0("runner_up_percentile_rank_", col, "_value")
1184 loser_pct_label_col <- paste0("loser_percentile_rank_", col)
1185 loser_pct_value_col <- paste0("loser_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001186 loser_pct_url_col <- paste0("loser_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001187 max_delta_pct_col <- paste0("max_delta_percentile_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001188 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1189 has_urls <- all(url_cols %in% names(comparison))
1190 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz130a2a22025-10-18 16:09:23 +02001191
1192 if (nrow(pct_values) == 0) {
1193 comparison[[winner_pct_label_col]] <- character(0)
1194 comparison[[winner_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001195 if (has_urls) {
1196 comparison[[winner_pct_url_col]] <- character(0)
1197 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001198 comparison[[runner_pct_label_col]] <- character(0)
1199 comparison[[runner_pct_value_col]] <- numeric(0)
1200 comparison[[loser_pct_label_col]] <- character(0)
1201 comparison[[loser_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001202 if (has_urls) {
1203 comparison[[loser_pct_url_col]] <- character(0)
1204 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001205 comparison[[max_delta_pct_col]] <- numeric(0)
1206 next
1207 }
1208
1209 pct_matrix <- as.matrix(pct_values)
1210 storage.mode(pct_matrix) <- "numeric"
1211
1212 n_rows <- nrow(pct_matrix)
1213 winner_labels <- rep(NA_character_, n_rows)
1214 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001215 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001216 runner_labels <- rep(NA_character_, n_rows)
1217 runner_values <- rep(NA_real_, n_rows)
1218 loser_labels <- rep(NA_character_, n_rows)
1219 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001220 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001221 max_deltas <- rep(NA_real_, n_rows)
1222
1223 if (n_rows > 0) {
1224 for (i in seq_len(n_rows)) {
1225 numeric_row <- as.numeric(pct_matrix[i, ])
1226 if (all(is.na(numeric_row))) {
1227 next
1228 }
1229
1230 if (any(is.na(numeric_row))) {
1231 numeric_row[is.na(numeric_row)] <- 0
1232 }
1233 pct_matrix[i, ] <- numeric_row
1234
1235 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1236 max_idx <- which(numeric_row == max_val)
1237 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
1238 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001239 if (has_urls) {
1240 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1241 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001242
1243 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1244 if (length(unique_vals) >= 2) {
1245 runner_val <- unique_vals[2]
1246 runner_idx <- which(numeric_row == runner_val)
1247 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
1248 runner_values[i] <- runner_val
1249 }
1250
1251 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1252 min_idx <- which(numeric_row == min_val)
1253 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
1254 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001255 if (has_urls) {
1256 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1257 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001258
1259 if (is.finite(max_val) && is.finite(min_val)) {
1260 max_deltas[i] <- max_val - min_val
1261 }
1262 }
1263 }
1264
1265 comparison[, pct_cols] <- pct_matrix
1266 comparison[[winner_pct_label_col]] <- winner_labels
1267 comparison[[winner_pct_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001268 if (has_urls) {
1269 comparison[[winner_pct_url_col]] <- winner_urls
1270 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001271 comparison[[runner_pct_label_col]] <- runner_labels
1272 comparison[[runner_pct_value_col]] <- runner_values
1273 comparison[[loser_pct_label_col]] <- loser_labels
1274 comparison[[loser_pct_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001275 if (has_urls) {
1276 comparison[[loser_pct_url_col]] <- loser_urls
1277 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001278 comparison[[max_delta_pct_col]] <- max_deltas
1279 }
1280
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +02001281 for (flag_col in names(imputed_flags)) {
1282 comparison[[flag_col]] <- imputed_flags[[flag_col]]
1283 }
1284 if (length(imputed_flags) > 0) {
1285 comparison$n_imputed <- as.integer(Reduce(`+`, lapply(imputed_flags, as.integer)))
1286 } else {
1287 comparison$n_imputed <- rep(0L, nrow(comparison))
1288 }
1289 comparison$imputed <- comparison$n_imputed > 0L
1290
Marc Kupietz09b1c082026-05-01 14:45:47 +02001291 collapse_consensus_url_columns <- function(url_cols) {
1292 if (length(url_cols) == 0) {
1293 return(rep(NA_character_, nrow(comparison)))
1294 }
1295 vapply(seq_len(nrow(comparison)), function(i) {
1296 urls <- unlist(comparison[i, url_cols, drop = FALSE], use.names = FALSE)
1297 urls <- as.character(urls)
1298 urls <- urls[!is.na(urls) & urls != ""]
1299 urls <- unique(urls)
1300 if (length(urls) == 1) {
1301 urls
1302 } else {
1303 NA_character_
1304 }
1305 }, character(1))
1306 }
1307
1308 winner_score_url_cols <- intersect(paste0("winner_", score_cols, "_webUIRequestUrl"), names(comparison))
1309 loser_score_url_cols <- intersect(paste0("loser_", score_cols, "_webUIRequestUrl"), names(comparison))
1310 if (length(winner_score_url_cols) > 0) {
1311 comparison$winner_webUIRequestUrl <- collapse_consensus_url_columns(winner_score_url_cols)
1312 }
1313 if (length(loser_score_url_cols) > 0) {
1314 comparison$loser_webUIRequestUrl <- collapse_consensus_url_columns(loser_score_url_cols)
1315 }
1316
1317 url_helper_cols <- intersect(paste0("webUIRequestUrl_", labels), names(comparison))
1318 if (length(url_helper_cols) > 0) {
1319 comparison <- dplyr::select(comparison, -dplyr::all_of(url_helper_cols))
1320 }
1321
Marc Kupietzc4540a22025-10-14 17:39:53 +02001322 dplyr::left_join(result, comparison, by = c("node", "collocate"))
1323}
1324
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001325#' @importFrom magrittr debug_pipe
Marc Kupietz2b17b212023-08-27 17:47:26 +02001326#' @importFrom stringr str_detect
1327#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1328#'
1329matches2FreqTable <- function(matches,
1330 index = 0,
1331 minOccur = 5,
1332 leftContextSize = 5,
1333 rightContextSize = 5,
1334 ignoreCollocateCase = FALSE,
1335 stopwords = c(),
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001336 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz2b17b212023-08-27 17:47:26 +02001337 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1338 verbose = TRUE) {
1339 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1340 frequency <- NULL
1341
1342 if (nrow(matches) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001343 dplyr::tibble(word = c(), frequency = c())
Marc Kupietz2b17b212023-08-27 17:47:26 +02001344 } else if (index == 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001345 if (!"tokens" %in% colnames(matches) || !is.list(matches$tokens)) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001346 log_info(verbose, "Outdated KorAP server: Falling back to client side tokenization.\n")
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001347 return(snippet2FreqTable(matches$snippet, minOccur, leftContextSize, rightContextSize,
1348 ignoreCollocateCase = ignoreCollocateCase,
1349 stopwords = stopwords, oldTable = oldTable, verbose = verbose
1350 ))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001351 }
1352 log_info(verbose, paste("Joining", nrow(matches), "kwics\n"))
Marc Kupietza25fbd92025-10-14 17:38:09 +02001353 for (i in seq_len(nrow(matches))) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001354 oldTable <- matches2FreqTable(
1355 matches,
1356 i,
1357 leftContextSize = leftContextSize,
1358 rightContextSize = rightContextSize,
1359 collocateFilterRegex = collocateFilterRegex,
1360 oldTable = oldTable,
1361 stopwords = stopwords
1362 )
1363 }
1364 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001365 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001366 group_by(word) |>
1367 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001368 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001369 arrange(desc(frequency))
1370 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001371 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietz2b17b212023-08-27 17:47:26 +02001372
1373 left <- tail(unlist(matches$tokens$left[index]), leftContextSize)
1374
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001375 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001376
1377 right <- head(unlist(matches$tokens$right[index]), rightContextSize)
1378
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001379 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001380
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001381 if (length(left) + length(right) == 0) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001382 oldTable
1383 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001384 table(c(left, right)) |>
1385 dplyr::as_tibble(.name_repair = "minimal") |>
1386 dplyr::rename(word = 1, frequency = 2) |>
1387 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001388 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001389 dplyr::bind_rows(oldTable)
1390 }
1391 }
1392}
1393
1394#' @importFrom magrittr debug_pipe
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001395#' @importFrom stringr str_match str_split str_detect
1396#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1397#'
1398snippet2FreqTable <- function(snippet,
1399 minOccur = 5,
1400 leftContextSize = 5,
1401 rightContextSize = 5,
1402 ignoreCollocateCase = FALSE,
1403 stopwords = c(),
1404 tokenizeRegex = "([! )(\uc2\uab,.:?\u201e\u201c\'\"]+|&quot;)",
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001405 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001406 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1407 verbose = TRUE) {
1408 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1409 frequency <- NULL
1410
1411 if (length(snippet) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001412 dplyr::tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001413 } else if (length(snippet) > 1) {
Marc Kupietza47d1502023-04-18 15:26:47 +02001414 log_info(verbose, paste("Joining", length(snippet), "kwics\n"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001415 for (s in snippet) {
1416 oldTable <- snippet2FreqTable(
1417 s,
1418 leftContextSize = leftContextSize,
1419 rightContextSize = rightContextSize,
Marc Kupietz47d0d2b2021-12-19 16:38:52 +01001420 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001421 oldTable = oldTable,
1422 stopwords = stopwords
1423 )
1424 }
Marc Kupietza47d1502023-04-18 15:26:47 +02001425 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001426 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001427 group_by(word) |>
1428 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001429 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001430 arrange(desc(frequency))
1431 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001432 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001433 match <-
1434 str_match(
1435 snippet,
1436 '<span class="context-left">(<span class="more"></span>)?(.*[^ ]) *</span><span class="match"><mark>.*</mark></span><span class="context-right"> *([^<]*)'
1437 )
1438
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001439 left <- if (leftContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001440 tail(unlist(str_split(match[1, 3], tokenizeRegex)), leftContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001441 } else {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001442 ""
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001443 }
1444 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001445
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001446 right <- if (rightContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001447 head(unlist(str_split(match[1, 4], tokenizeRegex)), rightContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001448 } else {
1449 ""
1450 }
1451 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001452
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001453 if (is.na(left[1]) || is.na(right[1]) || length(left) + length(right) == 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001454 oldTable
1455 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001456 table(c(left, right)) |>
1457 dplyr::as_tibble(.name_repair = "minimal") |>
1458 dplyr::rename(word = 1, frequency = 2) |>
1459 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001460 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001461 dplyr::bind_rows(oldTable)
1462 }
1463 }
1464}
1465
1466#' Preliminary synsemantic stopwords function
1467#'
1468#' @description
Marc Kupietz67edcb52021-09-20 21:54:24 +02001469#' `r lifecycle::badge("experimental")`
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001470#'
1471#' Preliminary synsemantic stopwords function to be used in collocation analysis.
1472#'
1473#' @details
1474#' Currently only suitable for German. See stopwords package for other languages.
1475#'
1476#' @param ... future arguments for language detection
1477#'
1478#' @family collocation analysis functions
1479#' @return Vector of synsemantic stopwords.
1480#' @export
1481synsemanticStopwords <- function(...) {
Marc Kupietzc79155b2025-10-19 13:42:55 +02001482 base <- c(
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001483 "der",
1484 "die",
1485 "und",
1486 "in",
1487 "den",
1488 "von",
1489 "mit",
1490 "das",
1491 "zu",
1492 "im",
1493 "ist",
1494 "auf",
1495 "sich",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001496 "des",
1497 "dem",
1498 "nicht",
1499 "ein",
1500 "eine",
1501 "es",
1502 "auch",
1503 "an",
1504 "als",
1505 "am",
1506 "aus",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001507 "bei",
1508 "er",
1509 "dass",
1510 "sie",
1511 "nach",
1512 "um",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001513 "zum",
1514 "noch",
1515 "war",
1516 "einen",
1517 "einer",
1518 "wie",
1519 "einem",
1520 "vor",
1521 "bis",
1522 "\u00fcber",
1523 "so",
1524 "aber",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001525 "diese",
Marc Kupietzc79155b2025-10-19 13:42:55 +02001526 "oder"
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001527 )
Marc Kupietzc79155b2025-10-19 13:42:55 +02001528
1529 lower <- unique(tolower(base))
1530 capitalized <- paste0(toupper(substr(lower, 1, 1)), substring(lower, 2))
1531
1532 unique(c(lower, capitalized))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001533}
1534
Marc Kupietz5a336b62021-11-27 17:51:35 +01001535
Marc Kupietz76b05592021-12-19 16:26:15 +01001536# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +01001537findExample <-
1538 function(kco,
1539 query,
1540 vc = "",
1541 matchOnly = TRUE) {
1542 out <- character(length = length(query))
1543
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001544 if (length(vc) < length(query)) {
Marc Kupietz5a336b62021-11-27 17:51:35 +01001545 vc <- rep(vc, length(query))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001546 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001547
1548 for (i in seq_along(query)) {
1549 q <- corpusQuery(kco, paste0("(", query[i], ")"), vc = vc[i], metadataOnly = FALSE)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001550 if (q@totalResults > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001551 q <- fetchNext(q, maxFetch = 50, randomizePageOrder = F)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001552 example <- as.character((q@collectedMatches)$snippet[1])
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001553 out[i] <- if (matchOnly) {
1554 gsub(".*<mark>(.+)</mark>.*", "\\1", example)
Marc Kupietz5a336b62021-11-27 17:51:35 +01001555 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001556 stringr::str_replace(example, "<[^>]*>", "")
Marc Kupietz5a336b62021-11-27 17:51:35 +01001557 }
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001558 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001559 out[i] <- ""
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001560 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001561 }
1562 out
1563 }
1564
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001565collocatesQuery <-
1566 function(kco,
1567 query,
1568 vc = "",
1569 minOccur = 5,
1570 leftContextSize = 5,
1571 rightContextSize = 5,
1572 searchHitsSampleLimit = 20000,
1573 ignoreCollocateCase = FALSE,
1574 stopwords = c(),
Marc Kupietzb2862d42025-10-18 10:17:49 +02001575 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001576 ...) {
1577 frequency <- NULL
1578 q <- corpusQuery(kco, query, vc, metadataOnly = F, ...)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001579 if (q@totalResults == 0) {
1580 tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001581 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001582 q <- fetchNext(q, maxFetch = searchHitsSampleLimit, randomizePageOrder = TRUE)
1583 matches2FreqTable(q@collectedMatches,
1584 0,
1585 minOccur = minOccur,
1586 leftContextSize = leftContextSize,
1587 rightContextSize = rightContextSize,
1588 ignoreCollocateCase = ignoreCollocateCase,
1589 stopwords = stopwords,
Marc Kupietzb2862d42025-10-18 10:17:49 +02001590 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001591 ...,
1592 verbose = kco@verbose
1593 ) |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001594 mutate(frequency = frequency * q@totalResults / min(q@totalResults, searchHitsSampleLimit)) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001595 filter(frequency >= minOccur)
1596 }
1597 }