blob: 806eb7f311215060893cc182047769bb8af00f23 [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 Kupietz4cbb5472025-10-19 12:15:25 +020046#' @param missingScoreQuantile lower quantile (evaluated per association measure) 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 below the weakest observed scores
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>}.
62#' \item Summary columns describing the strongest labels per measure (\code{winner_*}, \code{runner_up_*}, \code{loser_*}, and \code{max_delta_*}).
63#' \item Optional helper columns such as \code{query}, \code{example}, or \code{url} when example retrieval is requested.
64#' }
Marc Kupietzc4540a22025-10-14 17:39:53 +020065#' @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 +020066#' @importFrom purrr pmap
Marc Kupietzc4540a22025-10-14 17:39:53 +020067#' @importFrom tidyr expand_grid pivot_wider
68#' @importFrom rlang sym
Marc Kupietzdbd431a2021-08-29 12:17:45 +020069#'
70#' @examples
Marc Kupietz6ae76052021-09-21 10:34:00 +020071#' \dontrun{
72#'
Marc Kupietz6dfeed92025-06-03 11:58:06 +020073#' # Find top collocates of "Packung" inside and outside the sports domain.
74#' KorAPConnection(verbose = TRUE) |>
75#' collocationAnalysis("Packung",
76#' vc = c("textClass=sport", "textClass!=sport"),
77#' leftContextSize = 1, rightContextSize = 1, topCollocatesLimit = 20
78#' ) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +020079#' dplyr::filter(logDice >= 5)
80#' }
81#'
Marc Kupietz6ae76052021-09-21 10:34:00 +020082#' \dontrun{
83#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +020084#' # Identify the most prominent light verb construction with "in ... setzen".
85#' # Note that, currently, the use of focus function disallows exactFrequencies.
Marc Kupietz4cd066d2025-02-28 15:48:23 +010086#' KorAPConnection(verbose = TRUE) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +020087#' collocationAnalysis("focus(in [tt/p=NN] {[tt/l=setzen]})",
Marc Kupietz6dfeed92025-06-03 11:58:06 +020088#' leftContextSize = 1, rightContextSize = 0, exactFrequencies = FALSE, topCollocatesLimit = 20
89#' )
Marc Kupietzdbd431a2021-08-29 12:17:45 +020090#' }
91#'
92#' @export
Marc Kupietz6dfeed92025-06-03 11:58:06 +020093setMethod(
94 "collocationAnalysis", "KorAPConnection",
95 function(kco,
96 node,
97 vc = "",
98 lemmatizeNodeQuery = FALSE,
99 minOccur = 5,
100 leftContextSize = 5,
101 rightContextSize = 5,
102 topCollocatesLimit = 200,
103 searchHitsSampleLimit = 20000,
104 ignoreCollocateCase = FALSE,
105 withinSpan = ifelse(exactFrequencies, "base/s=s", ""),
106 exactFrequencies = TRUE,
107 stopwords = append(RKorAPClient::synsemanticStopwords(), node),
108 seed = 7,
109 expand = length(vc) != length(node),
110 maxRecurse = 0,
111 addExamples = FALSE,
112 thresholdScore = "logDice",
113 threshold = 2.0,
114 localStopwords = c(),
115 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzde679ea2025-10-19 13:14:51 +0200116 queryMissingScores = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200117 missingScoreQuantile = 0.05,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200118 vcLabel = NA_character_,
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200119 cacheAs = NULL,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200120 ...) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200121 word <- frequency <- O <- NULL
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200122
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200123 if (!is.null(cacheAs) && !grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
124 cacheAs <- paste0(cacheAs, ".rds")
125 }
126
127 if (!is.null(cacheAs) && file.exists(cacheAs)) {
128 log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
129 return(readRDS(cacheAs))
130 }
131
Marc Kupietzb2862d42025-10-18 10:17:49 +0200132 if (!exactFrequencies && (!is.na(withinSpan) && !is.null(withinSpan) && nzchar(withinSpan))) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200133 stop(sprintf("Not empty withinSpan (='%s') requires exactFrequencies=TRUE", withinSpan), call. = FALSE)
134 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200135
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200136 warnIfNotAuthorized(kco)
Marc Kupietz581a29b2021-09-04 20:51:04 +0200137
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200138 if (lemmatizeNodeQuery) {
139 node <- lemmatizeWordQuery(node)
140 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200141
Marc Kupietze34a8be2025-10-17 20:13:42 +0200142 vcNames <- names(vc)
Marc Kupietze34a8be2025-10-17 20:13:42 +0200143 if (is.null(vcNames)) {
144 vcNames <- rep(NA_character_, length(vc))
Marc Kupietze34a8be2025-10-17 20:13:42 +0200145 }
146
147 label_lookup <- NULL
Marc Kupietzb2862d42025-10-18 10:17:49 +0200148 if (!is.null(names(vc)) && length(vc) > 0) {
149 raw_names <- names(vc)
150 if (any(!is.na(raw_names) & raw_names != "")) {
151 label_lookup <- stats::setNames(raw_names, vc)
152 }
Marc Kupietze34a8be2025-10-17 20:13:42 +0200153 }
154
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200155 result <- if (length(node) > 1 || length(vc) > 1) {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200156 grid <- if (expand) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200157 tmp_grid <- tidyr::expand_grid(node = node, idx = seq_along(vc))
158 tmp_grid$vc <- vc[tmp_grid$idx]
159 tmp_grid$vcLabel <- vcNames[tmp_grid$idx]
160 tmp_grid[, c("node", "vc", "vcLabel"), drop = FALSE]
Marc Kupietze34a8be2025-10-17 20:13:42 +0200161 } else {
162 tibble(node = node, vc = vc, vcLabel = vcNames)
163 }
164
165 multi_result <- purrr::pmap(grid, function(node, vc, vcLabel, ...) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200166 collocationAnalysis(kco,
167 node = node,
168 vc = vc,
169 minOccur = minOccur,
170 leftContextSize = leftContextSize,
171 rightContextSize = rightContextSize,
172 topCollocatesLimit = topCollocatesLimit,
173 searchHitsSampleLimit = searchHitsSampleLimit,
174 ignoreCollocateCase = ignoreCollocateCase,
175 withinSpan = withinSpan,
176 exactFrequencies = exactFrequencies,
177 stopwords = stopwords,
178 addExamples = TRUE,
179 localStopwords = localStopwords,
180 seed = seed,
181 expand = expand,
Marc Kupietz9894a372025-10-18 14:51:29 +0200182 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200183 queryMissingScores = queryMissingScores,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200184 collocateFilterRegex = collocateFilterRegex,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200185 vcLabel = vcLabel,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200186 ...
187 )
188 }) |>
Marc Kupietze31322e2025-10-17 18:55:36 +0200189 bind_rows()
190
191 if (!"vc" %in% names(multi_result) || nrow(multi_result) == 0) {
192 multi_result
193 } else {
Marc Kupietzde679ea2025-10-19 13:14:51 +0200194 if (queryMissingScores) {
195 multi_result <- backfill_missing_scores(
196 multi_result,
197 grid = grid,
198 kco = kco,
199 ignoreCollocateCase = ignoreCollocateCase,
200 ...
201 )
202 }
203
Marc Kupietze34a8be2025-10-17 20:13:42 +0200204 if (!"label" %in% names(multi_result)) {
205 multi_result$label <- NA_character_
206 }
207
208 if (!is.null(label_lookup)) {
209 override <- unname(label_lookup[multi_result$vc])
210 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
211 if (any(missing_idx)) {
212 multi_result$label[missing_idx] <- override[missing_idx]
213 }
214 }
215
216 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
217 if (any(missing_idx)) {
218 multi_result$label[missing_idx] <- queryStringToLabel(multi_result$vc[missing_idx])
219 }
220
Marc Kupietze31322e2025-10-17 18:55:36 +0200221 multi_result |>
Marc Kupietz9894a372025-10-18 14:51:29 +0200222 add_multi_vc_comparisons(
Marc Kupietz9894a372025-10-18 14:51:29 +0200223 missingScoreQuantile = missingScoreQuantile
224 )
Marc Kupietze31322e2025-10-17 18:55:36 +0200225 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200226 } else {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200227 if ((is.na(vcLabel) || vcLabel == "") && length(vcNames) >= 1) {
228 vcLabel <- vcNames[1]
229 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200230
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200231 set.seed(seed)
232 candidates <- collocatesQuery(
233 kco,
234 node,
235 vc = vc,
236 minOccur = minOccur,
237 leftContextSize = leftContextSize,
238 rightContextSize = rightContextSize,
239 searchHitsSampleLimit = searchHitsSampleLimit,
240 ignoreCollocateCase = ignoreCollocateCase,
241 stopwords = append(stopwords, localStopwords),
Marc Kupietzb2862d42025-10-18 10:17:49 +0200242 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200243 ...
244 )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200245
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200246 if (nrow(candidates) > 0) {
247 candidates <- candidates |>
248 filter(frequency >= minOccur) |>
249 slice_head(n = topCollocatesLimit)
250 collocationScoreQuery(
251 kco,
252 node = node,
253 collocate = candidates$word,
254 vc = vc,
255 leftContextSize = leftContextSize,
256 rightContextSize = rightContextSize,
257 observed = if (exactFrequencies) NA else candidates$frequency,
258 ignoreCollocateCase = ignoreCollocateCase,
259 withinSpan = withinSpan,
260 ...
261 ) |>
262 filter(O >= minOccur) |>
263 dplyr::arrange(dplyr::desc(logDice))
264 } else {
265 tibble()
266 }
267 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200268
269 if (!is.na(vcLabel) && vcLabel != "" && "label" %in% names(result)) {
270 result$label <- rep(vcLabel, nrow(result))
271 }
272
273 threshold_col <- thresholdScore
274 if (maxRecurse > 0 && nrow(result) > 0 && threshold_col %in% names(result)) {
275 threshold_values <- result[[threshold_col]]
276 eligible_idx <- which(!is.na(threshold_values) & threshold_values >= threshold)
277 if (length(eligible_idx) > 0) {
278 recurseWith <- result[eligible_idx, , drop = FALSE]
279 result <- collocationAnalysis(
280 kco,
281 node = paste0("(", buildCollocationQuery(
282 removeWithinSpan(recurseWith$node, withinSpan),
283 recurseWith$collocate,
284 leftContextSize = leftContextSize,
285 rightContextSize = rightContextSize,
286 withinSpan = ""
287 ), ")"),
288 vc = vc,
289 minOccur = minOccur,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200290 leftContextSize = leftContextSize,
291 rightContextSize = rightContextSize,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200292 withinSpan = withinSpan,
293 maxRecurse = maxRecurse - 1,
294 stopwords = stopwords,
295 localStopwords = recurseWith$collocate,
296 exactFrequencies = exactFrequencies,
297 searchHitsSampleLimit = searchHitsSampleLimit,
298 topCollocatesLimit = topCollocatesLimit,
299 addExamples = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200300 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200301 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200302 queryMissingScores = queryMissingScores,
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200303 thresholdScore = thresholdScore,
304 threshold = threshold,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200305 vcLabel = vcLabel
306 ) |>
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200307 bind_rows(result)
308
309 if (threshold_col %in% names(result)) {
310 threshold_values <- result[[threshold_col]]
311 keep_idx <- is.na(threshold_values) | threshold_values >= threshold
312 result <- result[keep_idx, , drop = FALSE]
313 }
314
315 result <- result |>
Marc Kupietzb2862d42025-10-18 10:17:49 +0200316 filter(O >= minOccur) |>
317 dplyr::arrange(dplyr::desc(logDice))
318 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200319 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200320
321 if (addExamples && nrow(result) > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200322 result$query <- buildCollocationQuery(
323 result$node,
324 result$collocate,
325 leftContextSize = leftContextSize,
326 rightContextSize = rightContextSize,
327 withinSpan = withinSpan
328 )
329 result$example <- findExample(
330 kco,
331 query = result$query,
332 vc = result$vc
333 )
334 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200335
Marc Kupietz0a292632025-10-19 14:04:36 +0200336 if (!is.null(withinSpan) && !is.na(withinSpan) && nzchar(withinSpan) &&
337 nrow(result) > 0 &&
338 "webUIRequestUrl" %in% names(result) &&
339 "query" %in% names(result)) {
340 candidate_rows <- which(!is.na(result$node) &
341 !grepl("focus\\(", result$node, perl = TRUE) &
342 !is.na(result$query) & nzchar(result$query))
343
344 if (length(candidate_rows) > 0) {
345 focused_queries <- vapply(
346 result$query[candidate_rows],
347 inject_focus_into_query,
348 character(1)
349 )
350
351 changed <- focused_queries != result$query[candidate_rows]
352 if (any(changed)) {
353 indices <- candidate_rows[changed]
354 vc_values <- as.character(result$vc)
355 vc_values[is.na(vc_values)] <- ""
356
357 result$webUIRequestUrl[indices] <- mapply(
358 function(new_query, vc_value) {
359 buildWebUIRequestUrlFromString(
360 kco@KorAPUrl,
361 new_query,
362 vc = vc_value,
363 ql = "poliqarp"
364 )
365 },
366 focused_queries[changed],
367 vc_values[indices],
368 USE.NAMES = FALSE
369 )
370 }
371 }
372 }
373
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200374 if (!is.null(cacheAs)) {
375 log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", cacheAs))
376 saveRDS(result, cacheAs)
377 }
378
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200379 result
380 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200381)
382
Marc Kupietz76b05592021-12-19 16:26:15 +0100383# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +0100384removeWithinSpan <- function(query, withinSpan) {
385 if (withinSpan == "") {
386 return(query)
387 }
388 needle <- sprintf("^\\(contains\\(<%s>, ?(.*)\\){2}$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200389 res <- gsub(needle, "\\1", query)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100390 needle <- sprintf("^contains\\(<%s>, ?(.*)\\)$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200391 res <- gsub(needle, "\\1", res)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100392 return(res)
393}
394
Marc Kupietzde679ea2025-10-19 13:14:51 +0200395backfill_missing_scores <- function(result,
396 grid,
397 kco,
398 ignoreCollocateCase,
399 ...) {
400 if (!"vc" %in% names(result) || !"node" %in% names(result) || !"collocate" %in% names(result)) {
401 return(result)
402 }
403
404 if (nrow(result) == 0) {
405 return(result)
406 }
407
408 distinct_pairs <- dplyr::distinct(result, node, collocate)
409 if (nrow(distinct_pairs) == 0) {
410 return(result)
411 }
412
413 collocates_by_node <- split(as.character(distinct_pairs$collocate), distinct_pairs$node)
414 if (length(collocates_by_node) == 0) {
415 return(result)
416 }
417
418 required_combinations <- unique(as.data.frame(grid[, c("node", "vc", "vcLabel")], drop = FALSE))
419 for (i in seq_len(nrow(required_combinations))) {
420 node_value <- required_combinations$node[i]
421 vc_value <- required_combinations$vc[i]
422
423 collocate_pool <- collocates_by_node[[node_value]]
424 if (is.null(collocate_pool) || length(collocate_pool) == 0) {
425 next
426 }
427
428 existing_idx <- result$node == node_value & result$vc == vc_value
429 existing_collocates <- unique(as.character(result$collocate[existing_idx]))
430 missing_collocates <- setdiff(unique(collocate_pool), existing_collocates)
431 missing_collocates <- missing_collocates[!is.na(missing_collocates) & nzchar(missing_collocates)]
432
433 if (length(missing_collocates) == 0) {
434 next
435 }
436
437 context_rows <- result[result$node == node_value & result$vc == vc_value, , drop = FALSE]
438 if (nrow(context_rows) == 0) {
439 context_rows <- result[result$node == node_value, , drop = FALSE]
440 }
441
442 left_size <- context_rows$leftContextSize[!is.na(context_rows$leftContextSize)][1]
443 if (is.na(left_size) || length(left_size) == 0) {
444 left_size <- result$leftContextSize[!is.na(result$leftContextSize)][1]
445 }
446 if (is.na(left_size) || length(left_size) == 0) {
447 left_size <- 5
448 }
449
450 right_size <- context_rows$rightContextSize[!is.na(context_rows$rightContextSize)][1]
451 if (is.na(right_size) || length(right_size) == 0) {
452 right_size <- result$rightContextSize[!is.na(result$rightContextSize)][1]
453 }
454 if (is.na(right_size) || length(right_size) == 0) {
455 right_size <- 5
456 }
457
458 within_span_value <- ""
459 if ("query" %in% names(context_rows)) {
460 query_candidate <- context_rows$query[!is.na(context_rows$query) & nzchar(context_rows$query)][1]
461 if (!is.na(query_candidate) && nzchar(query_candidate)) {
462 match_one <- regexec("^\\(*contains\\(<([^>]+)>,", query_candidate)
463 matches <- regmatches(query_candidate, match_one)
464 if (length(matches) >= 1 && length(matches[[1]]) >= 2) {
465 within_span_value <- matches[[1]][2]
466 }
467 }
468 }
469
470 new_rows <- collocationScoreQuery(
471 kco,
472 node = node_value,
473 collocate = missing_collocates,
474 vc = vc_value,
475 leftContextSize = left_size,
476 rightContextSize = right_size,
477 ignoreCollocateCase = ignoreCollocateCase,
478 withinSpan = within_span_value,
479 ...
480 )
481
482 if (nrow(new_rows) == 0) {
483 next
484 }
485
486 if (!is.null(required_combinations$vcLabel[i]) && !is.na(required_combinations$vcLabel[i]) && required_combinations$vcLabel[i] != "" && "label" %in% names(new_rows)) {
487 new_rows$label <- required_combinations$vcLabel[i]
488 }
489
490 result <- dplyr::bind_rows(result, new_rows)
491 }
492
493 result
494}
495
Marc Kupietz0a292632025-10-19 14:04:36 +0200496inject_focus_into_query <- function(query) {
497 if (is.null(query) || is.na(query)) {
498 return(query)
499 }
500
501 trimmed <- trimws(query)
502 if (!nzchar(trimmed)) {
503 return(query)
504 }
505
506 if (!grepl("^contains\\(<[^>]+>", trimmed, perl = TRUE)) {
507 return(query)
508 }
509
510 if (grepl("focus\\(", trimmed, perl = TRUE)) {
511 return(query)
512 }
513
514 pattern <- "^contains\\(<([^>]+)>\\s*,\\s*\\((.*)\\)\\)\\s*$"
515 matches <- regexec(pattern, trimmed, perl = TRUE)
516 components <- regmatches(trimmed, matches)
517 if (length(components) == 0 || length(components[[1]]) < 3) {
518 return(query)
519 }
520
521 span <- components[[1]][2]
522 inner <- components[[1]][3]
523 parts <- strsplit(inner, "\\|", perl = TRUE)[[1]]
524 parts <- trimws(parts)
525 parts <- parts[nzchar(parts)]
526
527 if (length(parts) == 0) {
528 return(query)
529 }
530
531 focused <- paste0("focus({", parts, "})")
532 combined <- paste(focused, collapse = " | ")
533
534 sprintf("contains(<%s>, (%s))", span, combined)
535}
536
Marc Kupietz77852b22025-10-19 11:35:34 +0200537add_multi_vc_comparisons <- function(result, missingScoreQuantile = 0.05) {
Marc Kupietz28a29842025-10-18 12:25:09 +0200538 label <- node <- collocate <- NULL
Marc Kupietzc4540a22025-10-14 17:39:53 +0200539
540 if (!"label" %in% names(result) || dplyr::n_distinct(result$label) < 2) {
541 return(result)
542 }
543
544 numeric_cols <- names(result)[vapply(result, is.numeric, logical(1))]
545 non_score_cols <- c("N", "O", "O1", "O2", "E", "w", "leftContextSize", "rightContextSize", "frequency")
546 score_cols <- setdiff(numeric_cols, non_score_cols)
547
548 if (length(score_cols) == 0) {
549 return(result)
550 }
551
Marc Kupietz9894a372025-10-18 14:51:29 +0200552 compute_score_floor <- function(values) {
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200553 # Estimate a conservative floor so missing scores can be imputed without favoring any label
Marc Kupietz9894a372025-10-18 14:51:29 +0200554 finite_values <- values[is.finite(values)]
555 if (length(finite_values) == 0) {
556 return(0)
557 }
558
559 prob <- min(max(missingScoreQuantile, 0), 0.5)
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200560 # Use a lower quantile as the anchor to stay near the weakest attested scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200561 q_val <- suppressWarnings(stats::quantile(finite_values,
562 probs = prob,
563 names = FALSE,
564 type = 7
565 ))
566
567 if (!is.finite(q_val)) {
568 q_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
569 }
570
571 min_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
572 if (!is.finite(min_val)) {
573 min_val <- 0
574 }
575
576 spread_candidates <- c(
577 suppressWarnings(stats::IQR(finite_values, na.rm = TRUE, type = 7)),
578 stats::sd(finite_values, na.rm = TRUE),
579 abs(q_val) * 0.1,
580 abs(min_val - q_val)
581 )
582 spread_candidates <- spread_candidates[is.finite(spread_candidates)]
583
584 spread <- 0
585 if (length(spread_candidates) > 0) {
586 spread <- max(spread_candidates)
587 }
588 if (!is.finite(spread) || spread == 0) {
589 spread <- max(abs(q_val), abs(min_val), 1e-06)
590 }
591
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200592 # Step away from the anchor by a robust spread estimate to avoid ties with real scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200593 candidate <- q_val - spread
594 if (!is.finite(candidate)) {
595 candidate <- min_val
596 }
597
598 floor_value <- suppressWarnings(min(c(candidate, min_val), na.rm = TRUE))
599 if (!is.finite(floor_value)) {
600 floor_value <- min_val
601 }
602 if (!is.finite(floor_value)) {
603 floor_value <- 0
604 }
605
606 floor_value
607 }
608
609 score_replacements <- stats::setNames(
610 vapply(score_cols, function(col) {
611 compute_score_floor(result[[col]])
612 }, numeric(1)),
613 score_cols
614 )
615
Marc Kupietzc4540a22025-10-14 17:39:53 +0200616 comparison <- result |>
Marc Kupietz28a29842025-10-18 12:25:09 +0200617 dplyr::select(node, collocate, label, dplyr::all_of(score_cols)) |>
618 tidyr::pivot_wider(
Marc Kupietzc4540a22025-10-14 17:39:53 +0200619 names_from = label,
Marc Kupietz28a29842025-10-18 12:25:09 +0200620 values_from = dplyr::all_of(score_cols),
Marc Kupietzc4540a22025-10-14 17:39:53 +0200621 names_glue = "{.value}_{make.names(label)}",
622 values_fn = dplyr::first
623 )
624
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200625 raw_labels <- unique(result$label)
626 labels <- make.names(raw_labels)
627 label_map <- stats::setNames(raw_labels, labels)
Marc Kupietzc4540a22025-10-14 17:39:53 +0200628
Marc Kupietz28a29842025-10-18 12:25:09 +0200629 rank_data <- result |>
630 dplyr::distinct(node, collocate)
631
632 for (i in seq_along(raw_labels)) {
633 raw_lab <- raw_labels[i]
634 safe_lab <- labels[i]
635 label_df <- result[result$label == raw_lab, c("node", "collocate", score_cols), drop = FALSE]
636 if (nrow(label_df) == 0) {
637 next
638 }
639 label_df <- dplyr::distinct(label_df)
640 rank_tbl <- label_df[, c("node", "collocate"), drop = FALSE]
641 for (col in score_cols) {
642 rank_col_name <- paste0("rank_", safe_lab, "_", col)
Marc Kupietz130a2a22025-10-18 16:09:23 +0200643 percentile_col_name <- paste0("percentile_rank_", safe_lab, "_", col)
Marc Kupietz28a29842025-10-18 12:25:09 +0200644 values <- label_df[[col]]
645 ranks <- rep(NA_real_, length(values))
Marc Kupietz130a2a22025-10-18 16:09:23 +0200646 percentiles <- rep(NA_real_, length(values))
Marc Kupietz28a29842025-10-18 12:25:09 +0200647 valid_idx <- which(!is.na(values))
648 if (length(valid_idx) > 0) {
649 ranks[valid_idx] <- rank(-values[valid_idx], ties.method = "first")
Marc Kupietz130a2a22025-10-18 16:09:23 +0200650 total <- length(valid_idx)
651 percentiles[valid_idx] <- 1 - (ranks[valid_idx] - 1) / total
Marc Kupietz28a29842025-10-18 12:25:09 +0200652 }
653 rank_tbl[[rank_col_name]] <- ranks
Marc Kupietz130a2a22025-10-18 16:09:23 +0200654 rank_tbl[[percentile_col_name]] <- percentiles
Marc Kupietz28a29842025-10-18 12:25:09 +0200655 }
656 rank_data <- dplyr::left_join(rank_data, rank_tbl, by = c("node", "collocate"))
657 }
658
659 comparison <- dplyr::left_join(comparison, rank_data, by = c("node", "collocate"))
660
661 rank_replacements <- numeric(0)
662 rank_column_names <- grep("^rank_", names(comparison), value = TRUE)
663 if (length(rank_column_names) > 0) {
664 rank_replacements <- stats::setNames(
665 vapply(rank_column_names, function(col) {
666 col_values <- comparison[[col]]
667 valid_values <- col_values[!is.na(col_values)]
668 if (length(valid_values) == 0) {
669 nrow(comparison) + 1
670 } else {
671 suppressWarnings(max(valid_values, na.rm = TRUE)) + 1
672 }
673 }, numeric(1)),
674 rank_column_names
675 )
676 }
677
Marc Kupietz130a2a22025-10-18 16:09:23 +0200678 percentile_replacements <- numeric(0)
679 percentile_column_names <- grep("^percentile_rank_", names(comparison), value = TRUE)
680 if (length(percentile_column_names) > 0) {
681 percentile_replacements <- stats::setNames(
682 rep(0, length(percentile_column_names)),
683 percentile_column_names
684 )
685 }
686
Marc Kupietz28a29842025-10-18 12:25:09 +0200687 collapse_label_values <- function(indices, safe_labels_vec) {
688 if (length(indices) == 0) {
689 return(NA_character_)
690 }
691 labs <- label_map[safe_labels_vec[indices]]
692 fallback <- safe_labels_vec[indices]
693 labs[is.na(labs) | labs == ""] <- fallback[is.na(labs) | labs == ""]
694 labs <- labs[!is.na(labs) & labs != ""]
695 if (length(labs) == 0) {
696 return(NA_character_)
697 }
698 paste(unique(labs), collapse = ", ")
699 }
700
Marc Kupietzc4540a22025-10-14 17:39:53 +0200701 if (length(labels) == 2) {
Marc Kupietz9894a372025-10-18 14:51:29 +0200702 fill_scores <- function(x, y, measure_col) {
703 replacement <- score_replacements[[measure_col]]
704 fallback_min <- suppressWarnings(min(c(x, y), na.rm = TRUE))
705 if (!is.finite(fallback_min)) {
706 fallback_min <- 0
Marc Kupietzc4540a22025-10-14 17:39:53 +0200707 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200708 if (!is.null(replacement) && is.finite(replacement)) {
709 replacement <- min(replacement, fallback_min)
710 } else {
711 replacement <- fallback_min
712 }
713 if (!is.finite(replacement)) {
714 replacement <- 0
715 }
716 if (any(is.na(x))) {
717 x[is.na(x)] <- replacement
718 }
719 if (any(is.na(y))) {
720 y[is.na(y)] <- replacement
721 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200722 list(x = x, y = y)
723 }
724
Marc Kupietz130a2a22025-10-18 16:09:23 +0200725 fill_percentiles <- function(x, y, left_pct_col, right_pct_col) {
726 replacement_left <- percentile_replacements[[left_pct_col]]
727 if (is.null(replacement_left) || !is.finite(replacement_left)) {
728 replacement_left <- 0
729 }
730 replacement_right <- percentile_replacements[[right_pct_col]]
731 if (is.null(replacement_right) || !is.finite(replacement_right)) {
732 replacement_right <- 0
733 }
734 if (any(is.na(x))) {
735 x[is.na(x)] <- replacement_left
736 }
737 if (any(is.na(y))) {
738 y[is.na(y)] <- replacement_right
739 }
740 list(x = x, y = y)
741 }
742
Marc Kupietz28a29842025-10-18 12:25:09 +0200743 fill_ranks <- function(x, y, left_rank_col, right_rank_col) {
744 fallback <- nrow(comparison) + 1
745 replacement_left <- rank_replacements[[left_rank_col]]
746 if (is.null(replacement_left) || !is.finite(replacement_left)) {
747 replacement_left <- fallback
Marc Kupietzc4540a22025-10-14 17:39:53 +0200748 }
Marc Kupietz28a29842025-10-18 12:25:09 +0200749 replacement_right <- rank_replacements[[right_rank_col]]
750 if (is.null(replacement_right) || !is.finite(replacement_right)) {
751 replacement_right <- fallback
752 }
753 if (any(is.na(x))) {
754 x[is.na(x)] <- replacement_left
755 }
756 if (any(is.na(y))) {
757 y[is.na(y)] <- replacement_right
758 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200759 list(x = x, y = y)
760 }
761
762 left_label <- labels[1]
763 right_label <- labels[2]
764
765 for (col in score_cols) {
766 left_col <- paste0(col, "_", left_label)
767 right_col <- paste0(col, "_", right_label)
768 if (!all(c(left_col, right_col) %in% names(comparison))) {
769 next
770 }
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200771 filled <- fill_scores(comparison[[left_col]], comparison[[right_col]], col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200772 comparison[[left_col]] <- filled$x
773 comparison[[right_col]] <- filled$y
Marc Kupietzc4540a22025-10-14 17:39:53 +0200774 comparison[[paste0("delta_", col)]] <- filled$x - filled$y
Marc Kupietz28a29842025-10-18 12:25:09 +0200775 rank_left <- paste0("rank_", left_label, "_", col)
776 rank_right <- paste0("rank_", right_label, "_", col)
777 if (all(c(rank_left, rank_right) %in% names(comparison))) {
778 filled_rank <- fill_ranks(
779 comparison[[rank_left]],
780 comparison[[rank_right]],
781 rank_left,
782 rank_right
783 )
784 comparison[[paste0("delta_rank_", col)]] <- filled_rank$x - filled_rank$y
785 }
Marc Kupietz130a2a22025-10-18 16:09:23 +0200786 pct_left <- paste0("percentile_rank_", left_label, "_", col)
787 pct_right <- paste0("percentile_rank_", right_label, "_", col)
788 if (all(c(pct_left, pct_right) %in% names(comparison))) {
789 filled_pct <- fill_percentiles(
790 comparison[[pct_left]],
791 comparison[[pct_right]],
792 pct_left,
793 pct_right
794 )
795 comparison[[paste0("delta_percentile_rank_", col)]] <- filled_pct$x - filled_pct$y
796 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200797 }
798 }
799
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200800 for (col in score_cols) {
801 value_cols <- paste0(col, "_", labels)
802 existing <- value_cols %in% names(comparison)
803 if (!any(existing)) {
804 next
805 }
806 value_cols <- value_cols[existing]
807 safe_labels <- labels[existing]
808
809 score_values <- comparison[, value_cols, drop = FALSE]
810
811 winner_label_col <- paste0("winner_", col)
812 winner_value_col <- paste0("winner_", col, "_value")
813 runner_label_col <- paste0("runner_up_", col)
814 runner_value_col <- paste0("runner_up_", col, "_value")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200815 loser_label_col <- paste0("loser_", col)
816 loser_value_col <- paste0("loser_", col, "_value")
817 max_delta_col <- paste0("max_delta_", col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200818
819 if (nrow(score_values) == 0) {
820 comparison[[winner_label_col]] <- character(0)
821 comparison[[winner_value_col]] <- numeric(0)
822 comparison[[runner_label_col]] <- character(0)
823 comparison[[runner_value_col]] <- numeric(0)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200824 comparison[[loser_label_col]] <- character(0)
825 comparison[[loser_value_col]] <- numeric(0)
826 comparison[[max_delta_col]] <- numeric(0)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200827 next
828 }
829
830 score_matrix <- as.matrix(score_values)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200831 storage.mode(score_matrix) <- "numeric"
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200832
Marc Kupietzb2862d42025-10-18 10:17:49 +0200833 n_rows <- nrow(score_matrix)
834 winner_labels <- rep(NA_character_, n_rows)
835 winner_values <- rep(NA_real_, n_rows)
836 runner_labels <- rep(NA_character_, n_rows)
837 runner_values <- rep(NA_real_, n_rows)
838 loser_labels <- rep(NA_character_, n_rows)
839 loser_values <- rep(NA_real_, n_rows)
840 max_deltas <- rep(NA_real_, n_rows)
841
Marc Kupietzb2862d42025-10-18 10:17:49 +0200842 if (n_rows > 0) {
843 for (i in seq_len(n_rows)) {
844 numeric_row <- as.numeric(score_matrix[i, ])
845 if (all(is.na(numeric_row))) {
846 next
847 }
848
Marc Kupietz9894a372025-10-18 14:51:29 +0200849 replacement <- score_replacements[[col]]
850 fallback_min <- suppressWarnings(min(numeric_row, na.rm = TRUE))
851 if (!is.finite(fallback_min)) {
852 fallback_min <- 0
Marc Kupietzb2862d42025-10-18 10:17:49 +0200853 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200854 if (!is.null(replacement) && is.finite(replacement)) {
855 replacement <- min(replacement, fallback_min)
856 } else {
857 replacement <- fallback_min
858 }
859 if (!is.finite(replacement)) {
860 replacement <- 0
861 }
862 if (any(is.na(numeric_row))) {
863 numeric_row[is.na(numeric_row)] <- replacement
864 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200865 score_matrix[i, ] <- numeric_row
866
867 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
868 max_idx <- which(numeric_row == max_val)
Marc Kupietz28a29842025-10-18 12:25:09 +0200869 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200870 winner_values[i] <- max_val
871
872 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
873 if (length(unique_vals) >= 2) {
874 runner_val <- unique_vals[2]
875 runner_idx <- which(numeric_row == runner_val)
Marc Kupietz28a29842025-10-18 12:25:09 +0200876 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200877 runner_values[i] <- runner_val
878 }
879
880 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
881 min_idx <- which(numeric_row == min_val)
Marc Kupietz28a29842025-10-18 12:25:09 +0200882 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200883 loser_values[i] <- min_val
884
885 if (is.finite(max_val) && is.finite(min_val)) {
886 max_deltas[i] <- max_val - min_val
887 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200888 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200889 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200890
Marc Kupietzb2862d42025-10-18 10:17:49 +0200891 comparison[, value_cols] <- score_matrix
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200892 comparison[[winner_label_col]] <- winner_labels
893 comparison[[winner_value_col]] <- winner_values
894 comparison[[runner_label_col]] <- runner_labels
895 comparison[[runner_value_col]] <- runner_values
Marc Kupietzb2862d42025-10-18 10:17:49 +0200896 comparison[[loser_label_col]] <- loser_labels
897 comparison[[loser_value_col]] <- loser_values
898 comparison[[max_delta_col]] <- max_deltas
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200899 }
900
Marc Kupietz28a29842025-10-18 12:25:09 +0200901 for (col in score_cols) {
902 rank_cols <- paste0("rank_", labels, "_", col)
903 existing <- rank_cols %in% names(comparison)
904 if (!any(existing)) {
905 next
906 }
907 rank_cols <- rank_cols[existing]
908 safe_labels <- labels[existing]
909 rank_values <- comparison[, rank_cols, drop = FALSE]
910
911 winner_rank_label_col <- paste0("winner_rank_", col)
912 winner_rank_value_col <- paste0("winner_rank_", col, "_value")
913 runner_rank_label_col <- paste0("runner_up_rank_", col)
914 runner_rank_value_col <- paste0("runner_up_rank_", col, "_value")
915 loser_rank_label_col <- paste0("loser_rank_", col)
916 loser_rank_value_col <- paste0("loser_rank_", col, "_value")
917 max_delta_rank_col <- paste0("max_delta_rank_", col)
918
919 if (nrow(rank_values) == 0) {
920 comparison[[winner_rank_label_col]] <- character(0)
921 comparison[[winner_rank_value_col]] <- numeric(0)
922 comparison[[runner_rank_label_col]] <- character(0)
923 comparison[[runner_rank_value_col]] <- numeric(0)
924 comparison[[loser_rank_label_col]] <- character(0)
925 comparison[[loser_rank_value_col]] <- numeric(0)
926 comparison[[max_delta_rank_col]] <- numeric(0)
927 next
928 }
929
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200930 rank_matrix <- as.matrix(rank_values)
931 storage.mode(rank_matrix) <- "numeric"
Marc Kupietz28a29842025-10-18 12:25:09 +0200932
933 n_rows <- nrow(rank_matrix)
934 winner_labels <- rep(NA_character_, n_rows)
935 winner_values <- rep(NA_real_, n_rows)
936 runner_labels <- rep(NA_character_, n_rows)
937 runner_values <- rep(NA_real_, n_rows)
938 loser_labels <- rep(NA_character_, n_rows)
939 loser_values <- rep(NA_real_, n_rows)
940 max_deltas <- rep(NA_real_, n_rows)
941
942 for (i in seq_len(n_rows)) {
943 numeric_row <- as.numeric(rank_matrix[i, ])
944 if (all(is.na(numeric_row))) {
945 next
946 }
947
948 if (length(rank_cols) > 0) {
949 replacement_vec <- rank_replacements[rank_cols]
950 replacement_vec[is.na(replacement_vec)] <- nrow(comparison) + 1
951 missing_idx <- which(is.na(numeric_row))
952 if (length(missing_idx) > 0) {
953 numeric_row[missing_idx] <- replacement_vec[missing_idx]
954 }
955 }
956
957 valid_idx <- seq_along(numeric_row)
958 valid_values <- numeric_row[valid_idx]
959 min_val <- suppressWarnings(min(valid_values, na.rm = TRUE))
960 min_positions <- valid_idx[which(valid_values == min_val)]
961 winner_labels[i] <- collapse_label_values(min_positions, safe_labels)
962 winner_values[i] <- min_val
963
964 ordered_vals <- sort(unique(valid_values), decreasing = FALSE)
965 if (length(ordered_vals) >= 2) {
966 runner_val <- ordered_vals[2]
967 runner_positions <- valid_idx[which(valid_values == runner_val)]
968 runner_labels[i] <- collapse_label_values(runner_positions, safe_labels)
969 runner_values[i] <- runner_val
970 }
971
972 max_val <- suppressWarnings(max(valid_values, na.rm = TRUE))
973 max_positions <- valid_idx[which(valid_values == max_val)]
974 loser_labels[i] <- collapse_label_values(max_positions, safe_labels)
975 loser_values[i] <- max_val
976
977 if (is.finite(max_val) && is.finite(min_val)) {
978 max_deltas[i] <- max_val - min_val
979 }
980 }
981
982 comparison[[winner_rank_label_col]] <- winner_labels
983 comparison[[winner_rank_value_col]] <- winner_values
984 comparison[[runner_rank_label_col]] <- runner_labels
985 comparison[[runner_rank_value_col]] <- runner_values
986 comparison[[loser_rank_label_col]] <- loser_labels
987 comparison[[loser_rank_value_col]] <- loser_values
988 comparison[[max_delta_rank_col]] <- max_deltas
989 }
990
Marc Kupietz130a2a22025-10-18 16:09:23 +0200991 for (col in score_cols) {
992 pct_cols <- paste0("percentile_rank_", labels, "_", col)
993 existing <- pct_cols %in% names(comparison)
994 if (!any(existing)) {
995 next
996 }
997 pct_cols <- pct_cols[existing]
998 safe_labels <- labels[existing]
999 pct_values <- comparison[, pct_cols, drop = FALSE]
1000
1001 winner_pct_label_col <- paste0("winner_percentile_rank_", col)
1002 winner_pct_value_col <- paste0("winner_percentile_rank_", col, "_value")
1003 runner_pct_label_col <- paste0("runner_up_percentile_rank_", col)
1004 runner_pct_value_col <- paste0("runner_up_percentile_rank_", col, "_value")
1005 loser_pct_label_col <- paste0("loser_percentile_rank_", col)
1006 loser_pct_value_col <- paste0("loser_percentile_rank_", col, "_value")
1007 max_delta_pct_col <- paste0("max_delta_percentile_rank_", col)
1008
1009 if (nrow(pct_values) == 0) {
1010 comparison[[winner_pct_label_col]] <- character(0)
1011 comparison[[winner_pct_value_col]] <- numeric(0)
1012 comparison[[runner_pct_label_col]] <- character(0)
1013 comparison[[runner_pct_value_col]] <- numeric(0)
1014 comparison[[loser_pct_label_col]] <- character(0)
1015 comparison[[loser_pct_value_col]] <- numeric(0)
1016 comparison[[max_delta_pct_col]] <- numeric(0)
1017 next
1018 }
1019
1020 pct_matrix <- as.matrix(pct_values)
1021 storage.mode(pct_matrix) <- "numeric"
1022
1023 n_rows <- nrow(pct_matrix)
1024 winner_labels <- rep(NA_character_, n_rows)
1025 winner_values <- rep(NA_real_, n_rows)
1026 runner_labels <- rep(NA_character_, n_rows)
1027 runner_values <- rep(NA_real_, n_rows)
1028 loser_labels <- rep(NA_character_, n_rows)
1029 loser_values <- rep(NA_real_, n_rows)
1030 max_deltas <- rep(NA_real_, n_rows)
1031
1032 if (n_rows > 0) {
1033 for (i in seq_len(n_rows)) {
1034 numeric_row <- as.numeric(pct_matrix[i, ])
1035 if (all(is.na(numeric_row))) {
1036 next
1037 }
1038
1039 if (any(is.na(numeric_row))) {
1040 numeric_row[is.na(numeric_row)] <- 0
1041 }
1042 pct_matrix[i, ] <- numeric_row
1043
1044 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1045 max_idx <- which(numeric_row == max_val)
1046 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
1047 winner_values[i] <- max_val
1048
1049 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1050 if (length(unique_vals) >= 2) {
1051 runner_val <- unique_vals[2]
1052 runner_idx <- which(numeric_row == runner_val)
1053 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
1054 runner_values[i] <- runner_val
1055 }
1056
1057 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1058 min_idx <- which(numeric_row == min_val)
1059 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
1060 loser_values[i] <- min_val
1061
1062 if (is.finite(max_val) && is.finite(min_val)) {
1063 max_deltas[i] <- max_val - min_val
1064 }
1065 }
1066 }
1067
1068 comparison[, pct_cols] <- pct_matrix
1069 comparison[[winner_pct_label_col]] <- winner_labels
1070 comparison[[winner_pct_value_col]] <- winner_values
1071 comparison[[runner_pct_label_col]] <- runner_labels
1072 comparison[[runner_pct_value_col]] <- runner_values
1073 comparison[[loser_pct_label_col]] <- loser_labels
1074 comparison[[loser_pct_value_col]] <- loser_values
1075 comparison[[max_delta_pct_col]] <- max_deltas
1076 }
1077
Marc Kupietzc4540a22025-10-14 17:39:53 +02001078 dplyr::left_join(result, comparison, by = c("node", "collocate"))
1079}
1080
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001081#' @importFrom magrittr debug_pipe
Marc Kupietz2b17b212023-08-27 17:47:26 +02001082#' @importFrom stringr str_detect
1083#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1084#'
1085matches2FreqTable <- function(matches,
1086 index = 0,
1087 minOccur = 5,
1088 leftContextSize = 5,
1089 rightContextSize = 5,
1090 ignoreCollocateCase = FALSE,
1091 stopwords = c(),
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001092 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz2b17b212023-08-27 17:47:26 +02001093 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1094 verbose = TRUE) {
1095 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1096 frequency <- NULL
1097
1098 if (nrow(matches) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001099 dplyr::tibble(word = c(), frequency = c())
Marc Kupietz2b17b212023-08-27 17:47:26 +02001100 } else if (index == 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001101 if (!"tokens" %in% colnames(matches) || !is.list(matches$tokens)) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001102 log_info(verbose, "Outdated KorAP server: Falling back to client side tokenization.\n")
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001103 return(snippet2FreqTable(matches$snippet, minOccur, leftContextSize, rightContextSize,
1104 ignoreCollocateCase = ignoreCollocateCase,
1105 stopwords = stopwords, oldTable = oldTable, verbose = verbose
1106 ))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001107 }
1108 log_info(verbose, paste("Joining", nrow(matches), "kwics\n"))
Marc Kupietza25fbd92025-10-14 17:38:09 +02001109 for (i in seq_len(nrow(matches))) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001110 oldTable <- matches2FreqTable(
1111 matches,
1112 i,
1113 leftContextSize = leftContextSize,
1114 rightContextSize = rightContextSize,
1115 collocateFilterRegex = collocateFilterRegex,
1116 oldTable = oldTable,
1117 stopwords = stopwords
1118 )
1119 }
1120 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001121 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001122 group_by(word) |>
1123 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001124 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001125 arrange(desc(frequency))
1126 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001127 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietz2b17b212023-08-27 17:47:26 +02001128
1129 left <- tail(unlist(matches$tokens$left[index]), leftContextSize)
1130
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001131 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001132
1133 right <- head(unlist(matches$tokens$right[index]), rightContextSize)
1134
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001135 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001136
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001137 if (length(left) + length(right) == 0) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001138 oldTable
1139 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001140 table(c(left, right)) |>
1141 dplyr::as_tibble(.name_repair = "minimal") |>
1142 dplyr::rename(word = 1, frequency = 2) |>
1143 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001144 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001145 dplyr::bind_rows(oldTable)
1146 }
1147 }
1148}
1149
1150#' @importFrom magrittr debug_pipe
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001151#' @importFrom stringr str_match str_split str_detect
1152#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1153#'
1154snippet2FreqTable <- function(snippet,
1155 minOccur = 5,
1156 leftContextSize = 5,
1157 rightContextSize = 5,
1158 ignoreCollocateCase = FALSE,
1159 stopwords = c(),
1160 tokenizeRegex = "([! )(\uc2\uab,.:?\u201e\u201c\'\"]+|&quot;)",
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001161 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001162 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1163 verbose = TRUE) {
1164 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1165 frequency <- NULL
1166
1167 if (length(snippet) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001168 dplyr::tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001169 } else if (length(snippet) > 1) {
Marc Kupietza47d1502023-04-18 15:26:47 +02001170 log_info(verbose, paste("Joining", length(snippet), "kwics\n"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001171 for (s in snippet) {
1172 oldTable <- snippet2FreqTable(
1173 s,
1174 leftContextSize = leftContextSize,
1175 rightContextSize = rightContextSize,
Marc Kupietz47d0d2b2021-12-19 16:38:52 +01001176 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001177 oldTable = oldTable,
1178 stopwords = stopwords
1179 )
1180 }
Marc Kupietza47d1502023-04-18 15:26:47 +02001181 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001182 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001183 group_by(word) |>
1184 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001185 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001186 arrange(desc(frequency))
1187 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001188 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001189 match <-
1190 str_match(
1191 snippet,
1192 '<span class="context-left">(<span class="more"></span>)?(.*[^ ]) *</span><span class="match"><mark>.*</mark></span><span class="context-right"> *([^<]*)'
1193 )
1194
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001195 left <- if (leftContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001196 tail(unlist(str_split(match[1, 3], tokenizeRegex)), leftContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001197 } else {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001198 ""
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001199 }
1200 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001201
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001202 right <- if (rightContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001203 head(unlist(str_split(match[1, 4], tokenizeRegex)), rightContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001204 } else {
1205 ""
1206 }
1207 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001208
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001209 if (is.na(left[1]) || is.na(right[1]) || length(left) + length(right) == 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001210 oldTable
1211 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001212 table(c(left, right)) |>
1213 dplyr::as_tibble(.name_repair = "minimal") |>
1214 dplyr::rename(word = 1, frequency = 2) |>
1215 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001216 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001217 dplyr::bind_rows(oldTable)
1218 }
1219 }
1220}
1221
1222#' Preliminary synsemantic stopwords function
1223#'
1224#' @description
Marc Kupietz67edcb52021-09-20 21:54:24 +02001225#' `r lifecycle::badge("experimental")`
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001226#'
1227#' Preliminary synsemantic stopwords function to be used in collocation analysis.
1228#'
1229#' @details
1230#' Currently only suitable for German. See stopwords package for other languages.
1231#'
1232#' @param ... future arguments for language detection
1233#'
1234#' @family collocation analysis functions
1235#' @return Vector of synsemantic stopwords.
1236#' @export
1237synsemanticStopwords <- function(...) {
Marc Kupietzc79155b2025-10-19 13:42:55 +02001238 base <- c(
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001239 "der",
1240 "die",
1241 "und",
1242 "in",
1243 "den",
1244 "von",
1245 "mit",
1246 "das",
1247 "zu",
1248 "im",
1249 "ist",
1250 "auf",
1251 "sich",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001252 "des",
1253 "dem",
1254 "nicht",
1255 "ein",
1256 "eine",
1257 "es",
1258 "auch",
1259 "an",
1260 "als",
1261 "am",
1262 "aus",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001263 "bei",
1264 "er",
1265 "dass",
1266 "sie",
1267 "nach",
1268 "um",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001269 "zum",
1270 "noch",
1271 "war",
1272 "einen",
1273 "einer",
1274 "wie",
1275 "einem",
1276 "vor",
1277 "bis",
1278 "\u00fcber",
1279 "so",
1280 "aber",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001281 "diese",
Marc Kupietzc79155b2025-10-19 13:42:55 +02001282 "oder"
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001283 )
Marc Kupietzc79155b2025-10-19 13:42:55 +02001284
1285 lower <- unique(tolower(base))
1286 capitalized <- paste0(toupper(substr(lower, 1, 1)), substring(lower, 2))
1287
1288 unique(c(lower, capitalized))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001289}
1290
Marc Kupietz5a336b62021-11-27 17:51:35 +01001291
Marc Kupietz76b05592021-12-19 16:26:15 +01001292# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +01001293findExample <-
1294 function(kco,
1295 query,
1296 vc = "",
1297 matchOnly = TRUE) {
1298 out <- character(length = length(query))
1299
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001300 if (length(vc) < length(query)) {
Marc Kupietz5a336b62021-11-27 17:51:35 +01001301 vc <- rep(vc, length(query))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001302 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001303
1304 for (i in seq_along(query)) {
1305 q <- corpusQuery(kco, paste0("(", query[i], ")"), vc = vc[i], metadataOnly = FALSE)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001306 if (q@totalResults > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001307 q <- fetchNext(q, maxFetch = 50, randomizePageOrder = F)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001308 example <- as.character((q@collectedMatches)$snippet[1])
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001309 out[i] <- if (matchOnly) {
1310 gsub(".*<mark>(.+)</mark>.*", "\\1", example)
Marc Kupietz5a336b62021-11-27 17:51:35 +01001311 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001312 stringr::str_replace(example, "<[^>]*>", "")
Marc Kupietz5a336b62021-11-27 17:51:35 +01001313 }
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001314 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001315 out[i] <- ""
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001316 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001317 }
1318 out
1319 }
1320
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001321collocatesQuery <-
1322 function(kco,
1323 query,
1324 vc = "",
1325 minOccur = 5,
1326 leftContextSize = 5,
1327 rightContextSize = 5,
1328 searchHitsSampleLimit = 20000,
1329 ignoreCollocateCase = FALSE,
1330 stopwords = c(),
Marc Kupietzb2862d42025-10-18 10:17:49 +02001331 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001332 ...) {
1333 frequency <- NULL
1334 q <- corpusQuery(kco, query, vc, metadataOnly = F, ...)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001335 if (q@totalResults == 0) {
1336 tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001337 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001338 q <- fetchNext(q, maxFetch = searchHitsSampleLimit, randomizePageOrder = TRUE)
1339 matches2FreqTable(q@collectedMatches,
1340 0,
1341 minOccur = minOccur,
1342 leftContextSize = leftContextSize,
1343 rightContextSize = rightContextSize,
1344 ignoreCollocateCase = ignoreCollocateCase,
1345 stopwords = stopwords,
Marc Kupietzb2862d42025-10-18 10:17:49 +02001346 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001347 ...,
1348 verbose = kco@verbose
1349 ) |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001350 mutate(frequency = frequency * q@totalResults / min(q@totalResults, searchHitsSampleLimit)) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001351 filter(frequency >= minOccur)
1352 }
1353 }