blob: 4f827eb13937e0b58de4c30c75a0fafd9ff89d8e [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#'
Marc Kupietzba15cff2026-08-31 10:19:56 +020068#' `r lifecycle::badge("experimental")`
69#'
70#' The comparison columns produced when `vc` holds more than one virtual corpus
71#' are experimental: their names and semantics may still change in a future
72#' release without a deprecation cycle. Code that has to keep working across
73#' versions should select the columns it needs explicitly.
74#'
75#' They are an exploration aid, not a significance test. When reading them, keep
76#' three properties in mind.
Marc Kupietz95253342026-08-31 10:18:43 +020077#'
78#' \strong{Imputed scores describe presence/absence, not contrast.} A collocate
79#' that passes the `minOccur` and `topCollocatesLimit` thresholds in one virtual
80#' corpus but not in another has no observed score for the latter. Such cells are
81#' imputed from a floor derived from the pooled result set (see
82#' `missingScoreQuantile`), so the corresponding `delta_*` and `max_delta_*`
83#' values measure the distance to that floor rather than an attested difference.
84#' The `imputed`, `n_imputed` and `imputed_<label>` columns mark these rows;
85#' `dplyr::filter(!imputed)` restricts the result to collocates attested
86#' everywhere, and `queryMissingScores = TRUE` replaces most imputed cells with
87#' scores actually retrieved from the backend.
88#'
89#' \strong{Imputed values are relative to one analysis.} The floor is computed
90#' from the scores present in the result at hand. Analysing a node on its own and
91#' analysing it together with other nodes therefore yield different imputed
92#' values, and deltas involving imputed cells are not comparable across separate
93#' calls. Deltas between observed scores are unaffected.
94#'
95#' \strong{Winners carry no uncertainty.} Unlike [ci()], which attaches
96#' confidence intervals to relative frequencies, the `winner_*` / `loser_*`
97#' columns simply order point estimates. A collocate wins by a hair on six
98#' occurrences exactly as decisively as one that wins by a wide margin on
99#' thousands. Consult the observed frequencies (`O`, `O1`, `O2`) and the
100#' `webUIRequestUrl` concordance links before drawing conclusions from a
101#' small difference.
102#'
103#' Note also that `rank_<label>_<measure>` and
104#' `percentile_rank_<label>_<measure>` are computed within each label, over that
105#' label's own candidate set. Candidate sets usually differ in size between
106#' virtual corpora, so rank-based deltas compare positions in populations of
107#' different sizes.
Marc Kupietzc4540a22025-10-14 17:39:53 +0200108#' @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 +0200109#' @importFrom purrr pmap
Marc Kupietzc4540a22025-10-14 17:39:53 +0200110#' @importFrom tidyr expand_grid pivot_wider
111#' @importFrom rlang sym
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200112#'
113#' @examples
Marc Kupietz6ae76052021-09-21 10:34:00 +0200114#' \dontrun{
115#'
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200116#' # Find top collocates of "Packung" inside and outside the sports domain.
117#' KorAPConnection(verbose = TRUE) |>
118#' collocationAnalysis("Packung",
119#' vc = c("textClass=sport", "textClass!=sport"),
120#' leftContextSize = 1, rightContextSize = 1, topCollocatesLimit = 20
121#' ) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200122#' dplyr::filter(logDice >= 5)
123#' }
124#'
Marc Kupietz6ae76052021-09-21 10:34:00 +0200125#' \dontrun{
126#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200127#' # Identify the most prominent light verb construction with "in ... setzen".
128#' # Note that, currently, the use of focus function disallows exactFrequencies.
Marc Kupietz4cd066d2025-02-28 15:48:23 +0100129#' KorAPConnection(verbose = TRUE) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200130#' collocationAnalysis("focus(in [tt/p=NN] {[tt/l=setzen]})",
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200131#' leftContextSize = 1, rightContextSize = 0, exactFrequencies = FALSE, topCollocatesLimit = 20
132#' )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200133#' }
134#'
135#' @export
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200136setMethod(
137 "collocationAnalysis", "KorAPConnection",
138 function(kco,
139 node,
140 vc = "",
141 lemmatizeNodeQuery = FALSE,
142 minOccur = 5,
143 leftContextSize = 5,
144 rightContextSize = 5,
145 topCollocatesLimit = 200,
146 searchHitsSampleLimit = 20000,
147 ignoreCollocateCase = FALSE,
148 withinSpan = ifelse(exactFrequencies, "base/s=s", ""),
149 exactFrequencies = TRUE,
150 stopwords = append(RKorAPClient::synsemanticStopwords(), node),
151 seed = 7,
152 expand = length(vc) != length(node),
153 maxRecurse = 0,
154 addExamples = FALSE,
155 thresholdScore = "logDice",
156 threshold = 2.0,
157 localStopwords = c(),
158 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzde679ea2025-10-19 13:14:51 +0200159 queryMissingScores = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200160 missingScoreQuantile = 0.05,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200161 vcLabel = NA_character_,
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200162 cacheAs = NULL,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200163 ...) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200164 word <- frequency <- O <- NULL
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200165
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200166 if (!is.null(cacheAs) && !grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
167 cacheAs <- paste0(cacheAs, ".rds")
168 }
169
170 if (!is.null(cacheAs) && file.exists(cacheAs)) {
171 log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
172 return(readRDS(cacheAs))
173 }
174
Marc Kupietzb2862d42025-10-18 10:17:49 +0200175 if (!exactFrequencies && (!is.na(withinSpan) && !is.null(withinSpan) && nzchar(withinSpan))) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200176 stop(sprintf("Not empty withinSpan (='%s') requires exactFrequencies=TRUE", withinSpan), call. = FALSE)
177 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200178
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200179 warnIfNotAuthorized(kco)
Marc Kupietz581a29b2021-09-04 20:51:04 +0200180
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200181 if (lemmatizeNodeQuery) {
182 node <- lemmatizeWordQuery(node)
183 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200184
Marc Kupietze34a8be2025-10-17 20:13:42 +0200185 vcNames <- names(vc)
Marc Kupietze34a8be2025-10-17 20:13:42 +0200186 if (is.null(vcNames)) {
187 vcNames <- rep(NA_character_, length(vc))
Marc Kupietze34a8be2025-10-17 20:13:42 +0200188 }
189
190 label_lookup <- NULL
Marc Kupietzb2862d42025-10-18 10:17:49 +0200191 if (!is.null(names(vc)) && length(vc) > 0) {
192 raw_names <- names(vc)
193 if (any(!is.na(raw_names) & raw_names != "")) {
194 label_lookup <- stats::setNames(raw_names, vc)
195 }
Marc Kupietze34a8be2025-10-17 20:13:42 +0200196 }
197
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200198 result <- if (length(node) > 1 || length(vc) > 1) {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200199 grid <- if (expand) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200200 tmp_grid <- tidyr::expand_grid(node = node, idx = seq_along(vc))
201 tmp_grid$vc <- vc[tmp_grid$idx]
202 tmp_grid$vcLabel <- vcNames[tmp_grid$idx]
203 tmp_grid[, c("node", "vc", "vcLabel"), drop = FALSE]
Marc Kupietze34a8be2025-10-17 20:13:42 +0200204 } else {
205 tibble(node = node, vc = vc, vcLabel = vcNames)
206 }
207
208 multi_result <- purrr::pmap(grid, function(node, vc, vcLabel, ...) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200209 collocationAnalysis(kco,
210 node = node,
211 vc = vc,
212 minOccur = minOccur,
213 leftContextSize = leftContextSize,
214 rightContextSize = rightContextSize,
215 topCollocatesLimit = topCollocatesLimit,
216 searchHitsSampleLimit = searchHitsSampleLimit,
217 ignoreCollocateCase = ignoreCollocateCase,
218 withinSpan = withinSpan,
219 exactFrequencies = exactFrequencies,
220 stopwords = stopwords,
221 addExamples = TRUE,
222 localStopwords = localStopwords,
223 seed = seed,
224 expand = expand,
Marc Kupietz9894a372025-10-18 14:51:29 +0200225 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200226 queryMissingScores = queryMissingScores,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200227 collocateFilterRegex = collocateFilterRegex,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200228 vcLabel = vcLabel,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200229 ...
230 )
231 }) |>
Marc Kupietze31322e2025-10-17 18:55:36 +0200232 bind_rows()
233
234 if (!"vc" %in% names(multi_result) || nrow(multi_result) == 0) {
235 multi_result
236 } else {
Marc Kupietzde679ea2025-10-19 13:14:51 +0200237 if (queryMissingScores) {
238 multi_result <- backfill_missing_scores(
239 multi_result,
240 grid = grid,
241 kco = kco,
242 ignoreCollocateCase = ignoreCollocateCase,
243 ...
244 )
245 }
246
Marc Kupietze34a8be2025-10-17 20:13:42 +0200247 if (!"label" %in% names(multi_result)) {
248 multi_result$label <- NA_character_
249 }
250
251 if (!is.null(label_lookup)) {
252 override <- unname(label_lookup[multi_result$vc])
253 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
254 if (any(missing_idx)) {
255 multi_result$label[missing_idx] <- override[missing_idx]
256 }
257 }
258
259 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
260 if (any(missing_idx)) {
261 multi_result$label[missing_idx] <- queryStringToLabel(multi_result$vc[missing_idx])
262 }
263
Marc Kupietze31322e2025-10-17 18:55:36 +0200264 multi_result |>
Marc Kupietz9894a372025-10-18 14:51:29 +0200265 add_multi_vc_comparisons(
Marc Kupietz424cb782026-08-31 10:19:29 +0200266 missingScoreQuantile = missingScoreQuantile,
267 verbose = kco@verbose
Marc Kupietz9894a372025-10-18 14:51:29 +0200268 )
Marc Kupietze31322e2025-10-17 18:55:36 +0200269 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200270 } else {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200271 if ((is.na(vcLabel) || vcLabel == "") && length(vcNames) >= 1) {
272 vcLabel <- vcNames[1]
273 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200274
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200275 set.seed(seed)
276 candidates <- collocatesQuery(
277 kco,
278 node,
279 vc = vc,
280 minOccur = minOccur,
281 leftContextSize = leftContextSize,
282 rightContextSize = rightContextSize,
283 searchHitsSampleLimit = searchHitsSampleLimit,
284 ignoreCollocateCase = ignoreCollocateCase,
285 stopwords = append(stopwords, localStopwords),
Marc Kupietzb2862d42025-10-18 10:17:49 +0200286 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200287 ...
288 )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200289
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200290 if (nrow(candidates) > 0) {
291 candidates <- candidates |>
292 filter(frequency >= minOccur) |>
293 slice_head(n = topCollocatesLimit)
294 collocationScoreQuery(
295 kco,
296 node = node,
297 collocate = candidates$word,
298 vc = vc,
299 leftContextSize = leftContextSize,
300 rightContextSize = rightContextSize,
301 observed = if (exactFrequencies) NA else candidates$frequency,
302 ignoreCollocateCase = ignoreCollocateCase,
303 withinSpan = withinSpan,
304 ...
305 ) |>
306 filter(O >= minOccur) |>
307 dplyr::arrange(dplyr::desc(logDice))
308 } else {
309 tibble()
310 }
311 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200312
313 if (!is.na(vcLabel) && vcLabel != "" && "label" %in% names(result)) {
314 result$label <- rep(vcLabel, nrow(result))
315 }
316
317 threshold_col <- thresholdScore
318 if (maxRecurse > 0 && nrow(result) > 0 && threshold_col %in% names(result)) {
319 threshold_values <- result[[threshold_col]]
320 eligible_idx <- which(!is.na(threshold_values) & threshold_values >= threshold)
321 if (length(eligible_idx) > 0) {
322 recurseWith <- result[eligible_idx, , drop = FALSE]
323 result <- collocationAnalysis(
324 kco,
325 node = paste0("(", buildCollocationQuery(
326 removeWithinSpan(recurseWith$node, withinSpan),
327 recurseWith$collocate,
328 leftContextSize = leftContextSize,
329 rightContextSize = rightContextSize,
330 withinSpan = ""
331 ), ")"),
332 vc = vc,
333 minOccur = minOccur,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200334 leftContextSize = leftContextSize,
335 rightContextSize = rightContextSize,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200336 withinSpan = withinSpan,
337 maxRecurse = maxRecurse - 1,
338 stopwords = stopwords,
339 localStopwords = recurseWith$collocate,
340 exactFrequencies = exactFrequencies,
341 searchHitsSampleLimit = searchHitsSampleLimit,
342 topCollocatesLimit = topCollocatesLimit,
343 addExamples = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200344 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200345 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200346 queryMissingScores = queryMissingScores,
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200347 thresholdScore = thresholdScore,
348 threshold = threshold,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200349 vcLabel = vcLabel
350 ) |>
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200351 bind_rows(result)
352
353 if (threshold_col %in% names(result)) {
354 threshold_values <- result[[threshold_col]]
355 keep_idx <- is.na(threshold_values) | threshold_values >= threshold
356 result <- result[keep_idx, , drop = FALSE]
357 }
358
359 result <- result |>
Marc Kupietzb2862d42025-10-18 10:17:49 +0200360 filter(O >= minOccur) |>
361 dplyr::arrange(dplyr::desc(logDice))
362 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200363 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200364
365 if (addExamples && nrow(result) > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200366 result$query <- buildCollocationQuery(
367 result$node,
368 result$collocate,
369 leftContextSize = leftContextSize,
370 rightContextSize = rightContextSize,
371 withinSpan = withinSpan
372 )
373 result$example <- findExample(
374 kco,
375 query = result$query,
376 vc = result$vc
377 )
378 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200379
Marc Kupietz0a292632025-10-19 14:04:36 +0200380 if (!is.null(withinSpan) && !is.na(withinSpan) && nzchar(withinSpan) &&
381 nrow(result) > 0 &&
382 "webUIRequestUrl" %in% names(result) &&
383 "query" %in% names(result)) {
384 candidate_rows <- which(!is.na(result$node) &
385 !grepl("focus\\(", result$node, perl = TRUE) &
386 !is.na(result$query) & nzchar(result$query))
387
388 if (length(candidate_rows) > 0) {
389 focused_queries <- vapply(
390 result$query[candidate_rows],
391 inject_focus_into_query,
392 character(1)
393 )
394
395 changed <- focused_queries != result$query[candidate_rows]
396 if (any(changed)) {
397 indices <- candidate_rows[changed]
398 vc_values <- as.character(result$vc)
399 vc_values[is.na(vc_values)] <- ""
400
401 result$webUIRequestUrl[indices] <- mapply(
402 function(new_query, vc_value) {
403 buildWebUIRequestUrlFromString(
404 kco@KorAPUrl,
405 new_query,
406 vc = vc_value,
407 ql = "poliqarp"
408 )
409 },
410 focused_queries[changed],
411 vc_values[indices],
412 USE.NAMES = FALSE
413 )
414 }
415 }
416 }
417
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200418 if (!is.null(cacheAs)) {
419 log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", cacheAs))
420 saveRDS(result, cacheAs)
421 }
422
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200423 result
424 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200425)
426
Marc Kupietz76b05592021-12-19 16:26:15 +0100427# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +0100428removeWithinSpan <- function(query, withinSpan) {
429 if (withinSpan == "") {
430 return(query)
431 }
432 needle <- sprintf("^\\(contains\\(<%s>, ?(.*)\\){2}$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200433 res <- gsub(needle, "\\1", query)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100434 needle <- sprintf("^contains\\(<%s>, ?(.*)\\)$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200435 res <- gsub(needle, "\\1", res)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100436 return(res)
437}
438
Marc Kupietzde679ea2025-10-19 13:14:51 +0200439backfill_missing_scores <- function(result,
440 grid,
441 kco,
442 ignoreCollocateCase,
443 ...) {
444 if (!"vc" %in% names(result) || !"node" %in% names(result) || !"collocate" %in% names(result)) {
445 return(result)
446 }
447
448 if (nrow(result) == 0) {
449 return(result)
450 }
451
Marc Kupietz9c53e412026-06-21 12:13:44 +0200452 distinct_pairs <- dplyr::distinct(
453 result,
454 .data$node,
455 .data$collocate
456 )
Marc Kupietzde679ea2025-10-19 13:14:51 +0200457 if (nrow(distinct_pairs) == 0) {
458 return(result)
459 }
460
461 collocates_by_node <- split(as.character(distinct_pairs$collocate), distinct_pairs$node)
462 if (length(collocates_by_node) == 0) {
463 return(result)
464 }
465
466 required_combinations <- unique(as.data.frame(grid[, c("node", "vc", "vcLabel")], drop = FALSE))
467 for (i in seq_len(nrow(required_combinations))) {
468 node_value <- required_combinations$node[i]
469 vc_value <- required_combinations$vc[i]
470
471 collocate_pool <- collocates_by_node[[node_value]]
472 if (is.null(collocate_pool) || length(collocate_pool) == 0) {
473 next
474 }
475
476 existing_idx <- result$node == node_value & result$vc == vc_value
477 existing_collocates <- unique(as.character(result$collocate[existing_idx]))
478 missing_collocates <- setdiff(unique(collocate_pool), existing_collocates)
479 missing_collocates <- missing_collocates[!is.na(missing_collocates) & nzchar(missing_collocates)]
480
481 if (length(missing_collocates) == 0) {
482 next
483 }
484
485 context_rows <- result[result$node == node_value & result$vc == vc_value, , drop = FALSE]
486 if (nrow(context_rows) == 0) {
487 context_rows <- result[result$node == node_value, , drop = FALSE]
488 }
489
490 left_size <- context_rows$leftContextSize[!is.na(context_rows$leftContextSize)][1]
491 if (is.na(left_size) || length(left_size) == 0) {
492 left_size <- result$leftContextSize[!is.na(result$leftContextSize)][1]
493 }
494 if (is.na(left_size) || length(left_size) == 0) {
495 left_size <- 5
496 }
497
498 right_size <- context_rows$rightContextSize[!is.na(context_rows$rightContextSize)][1]
499 if (is.na(right_size) || length(right_size) == 0) {
500 right_size <- result$rightContextSize[!is.na(result$rightContextSize)][1]
501 }
502 if (is.na(right_size) || length(right_size) == 0) {
503 right_size <- 5
504 }
505
506 within_span_value <- ""
507 if ("query" %in% names(context_rows)) {
508 query_candidate <- context_rows$query[!is.na(context_rows$query) & nzchar(context_rows$query)][1]
509 if (!is.na(query_candidate) && nzchar(query_candidate)) {
510 match_one <- regexec("^\\(*contains\\(<([^>]+)>,", query_candidate)
511 matches <- regmatches(query_candidate, match_one)
512 if (length(matches) >= 1 && length(matches[[1]]) >= 2) {
513 within_span_value <- matches[[1]][2]
514 }
515 }
516 }
517
518 new_rows <- collocationScoreQuery(
519 kco,
520 node = node_value,
521 collocate = missing_collocates,
522 vc = vc_value,
523 leftContextSize = left_size,
524 rightContextSize = right_size,
525 ignoreCollocateCase = ignoreCollocateCase,
526 withinSpan = within_span_value,
527 ...
528 )
529
530 if (nrow(new_rows) == 0) {
531 next
532 }
533
534 if (!is.null(required_combinations$vcLabel[i]) && !is.na(required_combinations$vcLabel[i]) && required_combinations$vcLabel[i] != "" && "label" %in% names(new_rows)) {
535 new_rows$label <- required_combinations$vcLabel[i]
536 }
537
538 result <- dplyr::bind_rows(result, new_rows)
539 }
540
541 result
542}
543
Marc Kupietz0a292632025-10-19 14:04:36 +0200544inject_focus_into_query <- function(query) {
545 if (is.null(query) || is.na(query)) {
546 return(query)
547 }
548
549 trimmed <- trimws(query)
550 if (!nzchar(trimmed)) {
551 return(query)
552 }
553
554 if (!grepl("^contains\\(<[^>]+>", trimmed, perl = TRUE)) {
555 return(query)
556 }
557
558 if (grepl("focus\\(", trimmed, perl = TRUE)) {
559 return(query)
560 }
561
562 pattern <- "^contains\\(<([^>]+)>\\s*,\\s*\\((.*)\\)\\)\\s*$"
563 matches <- regexec(pattern, trimmed, perl = TRUE)
564 components <- regmatches(trimmed, matches)
565 if (length(components) == 0 || length(components[[1]]) < 3) {
566 return(query)
567 }
568
569 span <- components[[1]][2]
570 inner <- components[[1]][3]
571 parts <- strsplit(inner, "\\|", perl = TRUE)[[1]]
572 parts <- trimws(parts)
573 parts <- parts[nzchar(parts)]
574
575 if (length(parts) == 0) {
576 return(query)
577 }
578
579 focused <- paste0("focus({", parts, "})")
580 combined <- paste(focused, collapse = " | ")
581
582 sprintf("contains(<%s>, (%s))", span, combined)
583}
584
Marc Kupietz424cb782026-08-31 10:19:29 +0200585add_multi_vc_comparisons <- function(result, missingScoreQuantile = 0.05, verbose = FALSE) {
Marc Kupietz09b1c082026-05-01 14:45:47 +0200586 label <- node <- collocate <- vc <- webUIRequestUrl <- NULL
Marc Kupietzc4540a22025-10-14 17:39:53 +0200587
588 if (!"label" %in% names(result) || dplyr::n_distinct(result$label) < 2) {
589 return(result)
590 }
591
592 numeric_cols <- names(result)[vapply(result, is.numeric, logical(1))]
593 non_score_cols <- c("N", "O", "O1", "O2", "E", "w", "leftContextSize", "rightContextSize", "frequency")
594 score_cols <- setdiff(numeric_cols, non_score_cols)
595
596 if (length(score_cols) == 0) {
597 return(result)
598 }
599
Marc Kupietz9894a372025-10-18 14:51:29 +0200600 compute_score_floor <- function(values) {
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200601 # Estimate a conservative floor so missing scores can be imputed without favoring any label
Marc Kupietz9894a372025-10-18 14:51:29 +0200602 finite_values <- values[is.finite(values)]
603 if (length(finite_values) == 0) {
604 return(0)
605 }
606
607 prob <- min(max(missingScoreQuantile, 0), 0.5)
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200608 # Use a lower quantile as the anchor to stay near the weakest attested scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200609 q_val <- suppressWarnings(stats::quantile(finite_values,
610 probs = prob,
611 names = FALSE,
612 type = 7
613 ))
614
615 if (!is.finite(q_val)) {
616 q_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
617 }
618
619 min_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
620 if (!is.finite(min_val)) {
621 min_val <- 0
622 }
623
624 spread_candidates <- c(
625 suppressWarnings(stats::IQR(finite_values, na.rm = TRUE, type = 7)),
626 stats::sd(finite_values, na.rm = TRUE),
627 abs(q_val) * 0.1,
628 abs(min_val - q_val)
629 )
630 spread_candidates <- spread_candidates[is.finite(spread_candidates)]
631
632 spread <- 0
633 if (length(spread_candidates) > 0) {
634 spread <- max(spread_candidates)
635 }
636 if (!is.finite(spread) || spread == 0) {
637 spread <- max(abs(q_val), abs(min_val), 1e-06)
638 }
639
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200640 # Step away from the anchor by a robust spread estimate to avoid ties with real scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200641 candidate <- q_val - spread
642 if (!is.finite(candidate)) {
643 candidate <- min_val
644 }
645
646 floor_value <- suppressWarnings(min(c(candidate, min_val), na.rm = TRUE))
647 if (!is.finite(floor_value)) {
648 floor_value <- min_val
649 }
650 if (!is.finite(floor_value)) {
651 floor_value <- 0
652 }
653
654 floor_value
655 }
656
657 score_replacements <- stats::setNames(
658 vapply(score_cols, function(col) {
659 compute_score_floor(result[[col]])
660 }, numeric(1)),
661 score_cols
662 )
663
Marc Kupietzc4540a22025-10-14 17:39:53 +0200664 comparison <- result |>
Marc Kupietz28a29842025-10-18 12:25:09 +0200665 dplyr::select(node, collocate, label, dplyr::all_of(score_cols)) |>
666 tidyr::pivot_wider(
Marc Kupietzc4540a22025-10-14 17:39:53 +0200667 names_from = label,
Marc Kupietz28a29842025-10-18 12:25:09 +0200668 values_from = dplyr::all_of(score_cols),
Marc Kupietzc4540a22025-10-14 17:39:53 +0200669 names_glue = "{.value}_{make.names(label)}",
670 values_fn = dplyr::first
671 )
672
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200673 raw_labels <- unique(result$label)
674 labels <- make.names(raw_labels)
675 label_map <- stats::setNames(raw_labels, labels)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200676 vc_map <- result |>
677 dplyr::select(label, vc) |>
678 dplyr::filter(!is.na(label), label != "") |>
679 dplyr::distinct(label, .keep_all = TRUE)
680 vc_map <- stats::setNames(vc_map$vc, make.names(vc_map$label))
681
682 replace_web_ui_cq <- function(url, vc_value) {
683 if (length(url) == 0 || is.na(url) || url == "") {
684 return(NA_character_)
685 }
686 if (length(vc_value) == 0 || is.na(vc_value)) {
687 vc_value <- ""
688 }
689 encoded_vc <- urltools::url_encode(enc2utf8(as.character(vc_value)))
690 if (grepl("([?&]cq=)[^&]*", url, perl = TRUE)) {
691 return(sub("([?&]cq=)[^&]*", paste0("\\1", encoded_vc), url, perl = TRUE))
692 }
693 if (encoded_vc == "") {
694 return(url)
695 }
696 paste0(url, ifelse(grepl("\\?", url), "&", "?"), "cq=", encoded_vc)
697 }
698
699 if ("webUIRequestUrl" %in% names(result)) {
700 url_data <- result |>
701 dplyr::select(node, collocate, label, webUIRequestUrl) |>
702 tidyr::pivot_wider(
703 names_from = label,
704 values_from = webUIRequestUrl,
705 names_glue = "webUIRequestUrl_{make.names(label)}",
706 values_fn = dplyr::first
707 )
708
709 comparison <- dplyr::left_join(comparison, url_data, by = c("node", "collocate"))
710
711 url_cols <- paste0("webUIRequestUrl_", labels)
712 present_url_cols <- intersect(url_cols, names(comparison))
713 fallback_urls <- vapply(seq_len(nrow(comparison)), function(i) {
714 urls <- unlist(comparison[i, present_url_cols, drop = FALSE], use.names = FALSE)
715 urls <- as.character(urls)
716 urls <- urls[!is.na(urls) & urls != ""]
717 if (length(urls) == 0) {
718 NA_character_
719 } else {
720 urls[1]
721 }
722 }, character(1))
723
724 for (safe_label in labels) {
725 url_col <- paste0("webUIRequestUrl_", safe_label)
726 if (!url_col %in% names(comparison)) {
727 comparison[[url_col]] <- NA_character_
728 }
729 missing_urls <- is.na(comparison[[url_col]]) | comparison[[url_col]] == ""
730 if (any(missing_urls)) {
731 comparison[[url_col]][missing_urls] <- vapply(
732 fallback_urls[missing_urls],
733 replace_web_ui_cq,
734 character(1),
735 vc_value = vc_map[[safe_label]]
736 )
737 }
738 }
739 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200740
Marc Kupietz28a29842025-10-18 12:25:09 +0200741 rank_data <- result |>
742 dplyr::distinct(node, collocate)
743
744 for (i in seq_along(raw_labels)) {
745 raw_lab <- raw_labels[i]
746 safe_lab <- labels[i]
747 label_df <- result[result$label == raw_lab, c("node", "collocate", score_cols), drop = FALSE]
748 if (nrow(label_df) == 0) {
749 next
750 }
751 label_df <- dplyr::distinct(label_df)
752 rank_tbl <- label_df[, c("node", "collocate"), drop = FALSE]
753 for (col in score_cols) {
754 rank_col_name <- paste0("rank_", safe_lab, "_", col)
Marc Kupietz130a2a22025-10-18 16:09:23 +0200755 percentile_col_name <- paste0("percentile_rank_", safe_lab, "_", col)
Marc Kupietz28a29842025-10-18 12:25:09 +0200756 values <- label_df[[col]]
757 ranks <- rep(NA_real_, length(values))
Marc Kupietz130a2a22025-10-18 16:09:23 +0200758 percentiles <- rep(NA_real_, length(values))
Marc Kupietz28a29842025-10-18 12:25:09 +0200759 valid_idx <- which(!is.na(values))
760 if (length(valid_idx) > 0) {
761 ranks[valid_idx] <- rank(-values[valid_idx], ties.method = "first")
Marc Kupietz130a2a22025-10-18 16:09:23 +0200762 total <- length(valid_idx)
763 percentiles[valid_idx] <- 1 - (ranks[valid_idx] - 1) / total
Marc Kupietz28a29842025-10-18 12:25:09 +0200764 }
765 rank_tbl[[rank_col_name]] <- ranks
Marc Kupietz130a2a22025-10-18 16:09:23 +0200766 rank_tbl[[percentile_col_name]] <- percentiles
Marc Kupietz28a29842025-10-18 12:25:09 +0200767 }
768 rank_data <- dplyr::left_join(rank_data, rank_tbl, by = c("node", "collocate"))
769 }
770
771 comparison <- dplyr::left_join(comparison, rank_data, by = c("node", "collocate"))
772
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +0200773 # Record which label/measure cells are absent *before* any imputation happens below.
774 # Deltas computed from imputed cells reflect presence/absence of the collocate in a
775 # virtual corpus, not a measured contrast, so users need to be able to tell them apart.
776 imputed_flags <- lapply(labels, function(safe_label) {
777 label_score_cols <- intersect(paste0(score_cols, "_", safe_label), names(comparison))
778 if (length(label_score_cols) == 0) {
779 return(rep(FALSE, nrow(comparison)))
780 }
781 Reduce(`|`, lapply(label_score_cols, function(col) is.na(comparison[[col]])))
782 })
783 names(imputed_flags) <- paste0("imputed_", labels)
784
Marc Kupietz28a29842025-10-18 12:25:09 +0200785 rank_replacements <- numeric(0)
786 rank_column_names <- grep("^rank_", names(comparison), value = TRUE)
787 if (length(rank_column_names) > 0) {
788 rank_replacements <- stats::setNames(
789 vapply(rank_column_names, function(col) {
790 col_values <- comparison[[col]]
791 valid_values <- col_values[!is.na(col_values)]
792 if (length(valid_values) == 0) {
793 nrow(comparison) + 1
794 } else {
795 suppressWarnings(max(valid_values, na.rm = TRUE)) + 1
796 }
797 }, numeric(1)),
798 rank_column_names
799 )
800 }
801
Marc Kupietz130a2a22025-10-18 16:09:23 +0200802 percentile_replacements <- numeric(0)
803 percentile_column_names <- grep("^percentile_rank_", names(comparison), value = TRUE)
804 if (length(percentile_column_names) > 0) {
805 percentile_replacements <- stats::setNames(
806 rep(0, length(percentile_column_names)),
807 percentile_column_names
808 )
809 }
810
Marc Kupietz28a29842025-10-18 12:25:09 +0200811 collapse_label_values <- function(indices, safe_labels_vec) {
812 if (length(indices) == 0) {
813 return(NA_character_)
814 }
815 labs <- label_map[safe_labels_vec[indices]]
816 fallback <- safe_labels_vec[indices]
817 labs[is.na(labs) | labs == ""] <- fallback[is.na(labs) | labs == ""]
818 labs <- labs[!is.na(labs) & labs != ""]
819 if (length(labs) == 0) {
820 return(NA_character_)
821 }
822 paste(unique(labs), collapse = ", ")
823 }
824
Marc Kupietz09b1c082026-05-01 14:45:47 +0200825 collapse_url_values <- function(indices, url_values) {
826 if (length(indices) == 0 || is.null(url_values)) {
827 return(NA_character_)
828 }
829 urls <- as.character(url_values[indices])
830 urls <- urls[!is.na(urls) & urls != ""]
831 if (length(urls) == 0) {
832 return(NA_character_)
833 }
834 paste(unique(urls), collapse = ", ")
835 }
836
Marc Kupietzc4540a22025-10-14 17:39:53 +0200837 if (length(labels) == 2) {
Marc Kupietz9894a372025-10-18 14:51:29 +0200838 fill_scores <- function(x, y, measure_col) {
839 replacement <- score_replacements[[measure_col]]
840 fallback_min <- suppressWarnings(min(c(x, y), na.rm = TRUE))
841 if (!is.finite(fallback_min)) {
842 fallback_min <- 0
Marc Kupietzc4540a22025-10-14 17:39:53 +0200843 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200844 if (!is.null(replacement) && is.finite(replacement)) {
845 replacement <- min(replacement, fallback_min)
846 } else {
847 replacement <- fallback_min
848 }
849 if (!is.finite(replacement)) {
850 replacement <- 0
851 }
852 if (any(is.na(x))) {
853 x[is.na(x)] <- replacement
854 }
855 if (any(is.na(y))) {
856 y[is.na(y)] <- replacement
857 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200858 list(x = x, y = y)
859 }
860
Marc Kupietz130a2a22025-10-18 16:09:23 +0200861 fill_percentiles <- function(x, y, left_pct_col, right_pct_col) {
862 replacement_left <- percentile_replacements[[left_pct_col]]
863 if (is.null(replacement_left) || !is.finite(replacement_left)) {
864 replacement_left <- 0
865 }
866 replacement_right <- percentile_replacements[[right_pct_col]]
867 if (is.null(replacement_right) || !is.finite(replacement_right)) {
868 replacement_right <- 0
869 }
870 if (any(is.na(x))) {
871 x[is.na(x)] <- replacement_left
872 }
873 if (any(is.na(y))) {
874 y[is.na(y)] <- replacement_right
875 }
876 list(x = x, y = y)
877 }
878
Marc Kupietz28a29842025-10-18 12:25:09 +0200879 fill_ranks <- function(x, y, left_rank_col, right_rank_col) {
880 fallback <- nrow(comparison) + 1
881 replacement_left <- rank_replacements[[left_rank_col]]
882 if (is.null(replacement_left) || !is.finite(replacement_left)) {
883 replacement_left <- fallback
Marc Kupietzc4540a22025-10-14 17:39:53 +0200884 }
Marc Kupietz28a29842025-10-18 12:25:09 +0200885 replacement_right <- rank_replacements[[right_rank_col]]
886 if (is.null(replacement_right) || !is.finite(replacement_right)) {
887 replacement_right <- fallback
888 }
889 if (any(is.na(x))) {
890 x[is.na(x)] <- replacement_left
891 }
892 if (any(is.na(y))) {
893 y[is.na(y)] <- replacement_right
894 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200895 list(x = x, y = y)
896 }
897
898 left_label <- labels[1]
899 right_label <- labels[2]
900
901 for (col in score_cols) {
902 left_col <- paste0(col, "_", left_label)
903 right_col <- paste0(col, "_", right_label)
904 if (!all(c(left_col, right_col) %in% names(comparison))) {
905 next
906 }
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200907 filled <- fill_scores(comparison[[left_col]], comparison[[right_col]], col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200908 comparison[[left_col]] <- filled$x
909 comparison[[right_col]] <- filled$y
Marc Kupietzc4540a22025-10-14 17:39:53 +0200910 comparison[[paste0("delta_", col)]] <- filled$x - filled$y
Marc Kupietz28a29842025-10-18 12:25:09 +0200911 rank_left <- paste0("rank_", left_label, "_", col)
912 rank_right <- paste0("rank_", right_label, "_", col)
913 if (all(c(rank_left, rank_right) %in% names(comparison))) {
914 filled_rank <- fill_ranks(
915 comparison[[rank_left]],
916 comparison[[rank_right]],
917 rank_left,
918 rank_right
919 )
920 comparison[[paste0("delta_rank_", col)]] <- filled_rank$x - filled_rank$y
921 }
Marc Kupietz130a2a22025-10-18 16:09:23 +0200922 pct_left <- paste0("percentile_rank_", left_label, "_", col)
923 pct_right <- paste0("percentile_rank_", right_label, "_", col)
924 if (all(c(pct_left, pct_right) %in% names(comparison))) {
925 filled_pct <- fill_percentiles(
926 comparison[[pct_left]],
927 comparison[[pct_right]],
928 pct_left,
929 pct_right
930 )
931 comparison[[paste0("delta_percentile_rank_", col)]] <- filled_pct$x - filled_pct$y
932 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200933 }
934 }
935
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200936 for (col in score_cols) {
937 value_cols <- paste0(col, "_", labels)
938 existing <- value_cols %in% names(comparison)
939 if (!any(existing)) {
940 next
941 }
942 value_cols <- value_cols[existing]
943 safe_labels <- labels[existing]
944
945 score_values <- comparison[, value_cols, drop = FALSE]
946
947 winner_label_col <- paste0("winner_", col)
948 winner_value_col <- paste0("winner_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +0200949 winner_url_col <- paste0("winner_", col, "_webUIRequestUrl")
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200950 runner_label_col <- paste0("runner_up_", col)
951 runner_value_col <- paste0("runner_up_", col, "_value")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200952 loser_label_col <- paste0("loser_", col)
953 loser_value_col <- paste0("loser_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +0200954 loser_url_col <- paste0("loser_", col, "_webUIRequestUrl")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200955 max_delta_col <- paste0("max_delta_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200956 url_cols <- paste0("webUIRequestUrl_", safe_labels)
957 has_urls <- all(url_cols %in% names(comparison))
958 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200959
960 if (nrow(score_values) == 0) {
961 comparison[[winner_label_col]] <- character(0)
962 comparison[[winner_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200963 if (has_urls) {
964 comparison[[winner_url_col]] <- character(0)
965 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200966 comparison[[runner_label_col]] <- character(0)
967 comparison[[runner_value_col]] <- numeric(0)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200968 comparison[[loser_label_col]] <- character(0)
969 comparison[[loser_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200970 if (has_urls) {
971 comparison[[loser_url_col]] <- character(0)
972 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200973 comparison[[max_delta_col]] <- numeric(0)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200974 next
975 }
976
977 score_matrix <- as.matrix(score_values)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200978 storage.mode(score_matrix) <- "numeric"
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200979
Marc Kupietzb2862d42025-10-18 10:17:49 +0200980 n_rows <- nrow(score_matrix)
981 winner_labels <- rep(NA_character_, n_rows)
982 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200983 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200984 runner_labels <- rep(NA_character_, n_rows)
985 runner_values <- rep(NA_real_, n_rows)
986 loser_labels <- rep(NA_character_, n_rows)
987 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200988 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200989 max_deltas <- rep(NA_real_, n_rows)
990
Marc Kupietzb2862d42025-10-18 10:17:49 +0200991 if (n_rows > 0) {
992 for (i in seq_len(n_rows)) {
993 numeric_row <- as.numeric(score_matrix[i, ])
994 if (all(is.na(numeric_row))) {
995 next
996 }
997
Marc Kupietz9894a372025-10-18 14:51:29 +0200998 replacement <- score_replacements[[col]]
999 fallback_min <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1000 if (!is.finite(fallback_min)) {
1001 fallback_min <- 0
Marc Kupietzb2862d42025-10-18 10:17:49 +02001002 }
Marc Kupietz9894a372025-10-18 14:51:29 +02001003 if (!is.null(replacement) && is.finite(replacement)) {
1004 replacement <- min(replacement, fallback_min)
1005 } else {
1006 replacement <- fallback_min
1007 }
1008 if (!is.finite(replacement)) {
1009 replacement <- 0
1010 }
1011 if (any(is.na(numeric_row))) {
1012 numeric_row[is.na(numeric_row)] <- replacement
1013 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001014 score_matrix[i, ] <- numeric_row
1015
1016 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1017 max_idx <- which(numeric_row == max_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001018 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001019 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001020 if (has_urls) {
1021 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1022 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001023
1024 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1025 if (length(unique_vals) >= 2) {
1026 runner_val <- unique_vals[2]
1027 runner_idx <- which(numeric_row == runner_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001028 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001029 runner_values[i] <- runner_val
1030 }
1031
1032 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1033 min_idx <- which(numeric_row == min_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001034 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001035 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001036 if (has_urls) {
1037 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1038 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001039
1040 if (is.finite(max_val) && is.finite(min_val)) {
1041 max_deltas[i] <- max_val - min_val
1042 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001043 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001044 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001045
Marc Kupietzb2862d42025-10-18 10:17:49 +02001046 comparison[, value_cols] <- score_matrix
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001047 comparison[[winner_label_col]] <- winner_labels
1048 comparison[[winner_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001049 if (has_urls) {
1050 comparison[[winner_url_col]] <- winner_urls
1051 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001052 comparison[[runner_label_col]] <- runner_labels
1053 comparison[[runner_value_col]] <- runner_values
Marc Kupietzb2862d42025-10-18 10:17:49 +02001054 comparison[[loser_label_col]] <- loser_labels
1055 comparison[[loser_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001056 if (has_urls) {
1057 comparison[[loser_url_col]] <- loser_urls
1058 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001059 comparison[[max_delta_col]] <- max_deltas
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001060 }
1061
Marc Kupietz28a29842025-10-18 12:25:09 +02001062 for (col in score_cols) {
1063 rank_cols <- paste0("rank_", labels, "_", col)
1064 existing <- rank_cols %in% names(comparison)
1065 if (!any(existing)) {
1066 next
1067 }
1068 rank_cols <- rank_cols[existing]
1069 safe_labels <- labels[existing]
1070 rank_values <- comparison[, rank_cols, drop = FALSE]
1071
1072 winner_rank_label_col <- paste0("winner_rank_", col)
1073 winner_rank_value_col <- paste0("winner_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001074 winner_rank_url_col <- paste0("winner_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001075 runner_rank_label_col <- paste0("runner_up_rank_", col)
1076 runner_rank_value_col <- paste0("runner_up_rank_", col, "_value")
1077 loser_rank_label_col <- paste0("loser_rank_", col)
1078 loser_rank_value_col <- paste0("loser_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001079 loser_rank_url_col <- paste0("loser_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001080 max_delta_rank_col <- paste0("max_delta_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001081 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1082 has_urls <- all(url_cols %in% names(comparison))
1083 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz28a29842025-10-18 12:25:09 +02001084
1085 if (nrow(rank_values) == 0) {
1086 comparison[[winner_rank_label_col]] <- character(0)
1087 comparison[[winner_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001088 if (has_urls) {
1089 comparison[[winner_rank_url_col]] <- character(0)
1090 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001091 comparison[[runner_rank_label_col]] <- character(0)
1092 comparison[[runner_rank_value_col]] <- numeric(0)
1093 comparison[[loser_rank_label_col]] <- character(0)
1094 comparison[[loser_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001095 if (has_urls) {
1096 comparison[[loser_rank_url_col]] <- character(0)
1097 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001098 comparison[[max_delta_rank_col]] <- numeric(0)
1099 next
1100 }
1101
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001102 rank_matrix <- as.matrix(rank_values)
1103 storage.mode(rank_matrix) <- "numeric"
Marc Kupietz28a29842025-10-18 12:25:09 +02001104
1105 n_rows <- nrow(rank_matrix)
1106 winner_labels <- rep(NA_character_, n_rows)
1107 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001108 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001109 runner_labels <- rep(NA_character_, n_rows)
1110 runner_values <- rep(NA_real_, n_rows)
1111 loser_labels <- rep(NA_character_, n_rows)
1112 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001113 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001114 max_deltas <- rep(NA_real_, n_rows)
1115
1116 for (i in seq_len(n_rows)) {
1117 numeric_row <- as.numeric(rank_matrix[i, ])
1118 if (all(is.na(numeric_row))) {
1119 next
1120 }
1121
1122 if (length(rank_cols) > 0) {
1123 replacement_vec <- rank_replacements[rank_cols]
1124 replacement_vec[is.na(replacement_vec)] <- nrow(comparison) + 1
1125 missing_idx <- which(is.na(numeric_row))
1126 if (length(missing_idx) > 0) {
1127 numeric_row[missing_idx] <- replacement_vec[missing_idx]
1128 }
1129 }
1130
1131 valid_idx <- seq_along(numeric_row)
1132 valid_values <- numeric_row[valid_idx]
1133 min_val <- suppressWarnings(min(valid_values, na.rm = TRUE))
1134 min_positions <- valid_idx[which(valid_values == min_val)]
1135 winner_labels[i] <- collapse_label_values(min_positions, safe_labels)
1136 winner_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001137 if (has_urls) {
1138 winner_urls[i] <- collapse_url_values(min_positions, url_values[i, ])
1139 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001140
1141 ordered_vals <- sort(unique(valid_values), decreasing = FALSE)
1142 if (length(ordered_vals) >= 2) {
1143 runner_val <- ordered_vals[2]
1144 runner_positions <- valid_idx[which(valid_values == runner_val)]
1145 runner_labels[i] <- collapse_label_values(runner_positions, safe_labels)
1146 runner_values[i] <- runner_val
1147 }
1148
1149 max_val <- suppressWarnings(max(valid_values, na.rm = TRUE))
1150 max_positions <- valid_idx[which(valid_values == max_val)]
1151 loser_labels[i] <- collapse_label_values(max_positions, safe_labels)
1152 loser_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001153 if (has_urls) {
1154 loser_urls[i] <- collapse_url_values(max_positions, url_values[i, ])
1155 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001156
1157 if (is.finite(max_val) && is.finite(min_val)) {
1158 max_deltas[i] <- max_val - min_val
1159 }
1160 }
1161
1162 comparison[[winner_rank_label_col]] <- winner_labels
1163 comparison[[winner_rank_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001164 if (has_urls) {
1165 comparison[[winner_rank_url_col]] <- winner_urls
1166 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001167 comparison[[runner_rank_label_col]] <- runner_labels
1168 comparison[[runner_rank_value_col]] <- runner_values
1169 comparison[[loser_rank_label_col]] <- loser_labels
1170 comparison[[loser_rank_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001171 if (has_urls) {
1172 comparison[[loser_rank_url_col]] <- loser_urls
1173 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001174 comparison[[max_delta_rank_col]] <- max_deltas
1175 }
1176
Marc Kupietz130a2a22025-10-18 16:09:23 +02001177 for (col in score_cols) {
1178 pct_cols <- paste0("percentile_rank_", labels, "_", col)
1179 existing <- pct_cols %in% names(comparison)
1180 if (!any(existing)) {
1181 next
1182 }
1183 pct_cols <- pct_cols[existing]
1184 safe_labels <- labels[existing]
1185 pct_values <- comparison[, pct_cols, drop = FALSE]
1186
1187 winner_pct_label_col <- paste0("winner_percentile_rank_", col)
1188 winner_pct_value_col <- paste0("winner_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001189 winner_pct_url_col <- paste0("winner_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001190 runner_pct_label_col <- paste0("runner_up_percentile_rank_", col)
1191 runner_pct_value_col <- paste0("runner_up_percentile_rank_", col, "_value")
1192 loser_pct_label_col <- paste0("loser_percentile_rank_", col)
1193 loser_pct_value_col <- paste0("loser_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001194 loser_pct_url_col <- paste0("loser_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001195 max_delta_pct_col <- paste0("max_delta_percentile_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001196 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1197 has_urls <- all(url_cols %in% names(comparison))
1198 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz130a2a22025-10-18 16:09:23 +02001199
1200 if (nrow(pct_values) == 0) {
1201 comparison[[winner_pct_label_col]] <- character(0)
1202 comparison[[winner_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001203 if (has_urls) {
1204 comparison[[winner_pct_url_col]] <- character(0)
1205 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001206 comparison[[runner_pct_label_col]] <- character(0)
1207 comparison[[runner_pct_value_col]] <- numeric(0)
1208 comparison[[loser_pct_label_col]] <- character(0)
1209 comparison[[loser_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001210 if (has_urls) {
1211 comparison[[loser_pct_url_col]] <- character(0)
1212 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001213 comparison[[max_delta_pct_col]] <- numeric(0)
1214 next
1215 }
1216
1217 pct_matrix <- as.matrix(pct_values)
1218 storage.mode(pct_matrix) <- "numeric"
1219
1220 n_rows <- nrow(pct_matrix)
1221 winner_labels <- rep(NA_character_, n_rows)
1222 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001223 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001224 runner_labels <- rep(NA_character_, n_rows)
1225 runner_values <- rep(NA_real_, n_rows)
1226 loser_labels <- rep(NA_character_, n_rows)
1227 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001228 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001229 max_deltas <- rep(NA_real_, n_rows)
1230
1231 if (n_rows > 0) {
1232 for (i in seq_len(n_rows)) {
1233 numeric_row <- as.numeric(pct_matrix[i, ])
1234 if (all(is.na(numeric_row))) {
1235 next
1236 }
1237
1238 if (any(is.na(numeric_row))) {
1239 numeric_row[is.na(numeric_row)] <- 0
1240 }
1241 pct_matrix[i, ] <- numeric_row
1242
1243 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1244 max_idx <- which(numeric_row == max_val)
1245 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
1246 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001247 if (has_urls) {
1248 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1249 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001250
1251 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1252 if (length(unique_vals) >= 2) {
1253 runner_val <- unique_vals[2]
1254 runner_idx <- which(numeric_row == runner_val)
1255 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
1256 runner_values[i] <- runner_val
1257 }
1258
1259 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1260 min_idx <- which(numeric_row == min_val)
1261 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
1262 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001263 if (has_urls) {
1264 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1265 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001266
1267 if (is.finite(max_val) && is.finite(min_val)) {
1268 max_deltas[i] <- max_val - min_val
1269 }
1270 }
1271 }
1272
1273 comparison[, pct_cols] <- pct_matrix
1274 comparison[[winner_pct_label_col]] <- winner_labels
1275 comparison[[winner_pct_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001276 if (has_urls) {
1277 comparison[[winner_pct_url_col]] <- winner_urls
1278 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001279 comparison[[runner_pct_label_col]] <- runner_labels
1280 comparison[[runner_pct_value_col]] <- runner_values
1281 comparison[[loser_pct_label_col]] <- loser_labels
1282 comparison[[loser_pct_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001283 if (has_urls) {
1284 comparison[[loser_pct_url_col]] <- loser_urls
1285 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001286 comparison[[max_delta_pct_col]] <- max_deltas
1287 }
1288
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +02001289 for (flag_col in names(imputed_flags)) {
1290 comparison[[flag_col]] <- imputed_flags[[flag_col]]
1291 }
1292 if (length(imputed_flags) > 0) {
1293 comparison$n_imputed <- as.integer(Reduce(`+`, lapply(imputed_flags, as.integer)))
1294 } else {
1295 comparison$n_imputed <- rep(0L, nrow(comparison))
1296 }
1297 comparison$imputed <- comparison$n_imputed > 0L
1298
Marc Kupietz424cb782026-08-31 10:19:29 +02001299 n_imputed_rows <- sum(comparison$imputed)
1300 if (n_imputed_rows > 0) {
1301 log_info(verbose, sprintf(
1302 paste0(
1303 "Imputed scores for %d of %d node/collocate combinations (%d of %d label cells) ",
1304 "that are not attested in every virtual corpus. Their delta and winner/loser ",
1305 "columns reflect presence vs. absence rather than a measured contrast; see the ",
1306 "`imputed` column and `queryMissingScores`.\n"
1307 ),
1308 n_imputed_rows,
1309 nrow(comparison),
1310 sum(comparison$n_imputed),
1311 nrow(comparison) * length(labels)
1312 ))
1313 }
1314
Marc Kupietz09b1c082026-05-01 14:45:47 +02001315 collapse_consensus_url_columns <- function(url_cols) {
1316 if (length(url_cols) == 0) {
1317 return(rep(NA_character_, nrow(comparison)))
1318 }
1319 vapply(seq_len(nrow(comparison)), function(i) {
1320 urls <- unlist(comparison[i, url_cols, drop = FALSE], use.names = FALSE)
1321 urls <- as.character(urls)
1322 urls <- urls[!is.na(urls) & urls != ""]
1323 urls <- unique(urls)
1324 if (length(urls) == 1) {
1325 urls
1326 } else {
1327 NA_character_
1328 }
1329 }, character(1))
1330 }
1331
1332 winner_score_url_cols <- intersect(paste0("winner_", score_cols, "_webUIRequestUrl"), names(comparison))
1333 loser_score_url_cols <- intersect(paste0("loser_", score_cols, "_webUIRequestUrl"), names(comparison))
1334 if (length(winner_score_url_cols) > 0) {
1335 comparison$winner_webUIRequestUrl <- collapse_consensus_url_columns(winner_score_url_cols)
1336 }
1337 if (length(loser_score_url_cols) > 0) {
1338 comparison$loser_webUIRequestUrl <- collapse_consensus_url_columns(loser_score_url_cols)
1339 }
1340
1341 url_helper_cols <- intersect(paste0("webUIRequestUrl_", labels), names(comparison))
1342 if (length(url_helper_cols) > 0) {
1343 comparison <- dplyr::select(comparison, -dplyr::all_of(url_helper_cols))
1344 }
1345
Marc Kupietzc4540a22025-10-14 17:39:53 +02001346 dplyr::left_join(result, comparison, by = c("node", "collocate"))
1347}
1348
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001349#' @importFrom magrittr debug_pipe
Marc Kupietz2b17b212023-08-27 17:47:26 +02001350#' @importFrom stringr str_detect
1351#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1352#'
1353matches2FreqTable <- function(matches,
1354 index = 0,
1355 minOccur = 5,
1356 leftContextSize = 5,
1357 rightContextSize = 5,
1358 ignoreCollocateCase = FALSE,
1359 stopwords = c(),
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001360 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz2b17b212023-08-27 17:47:26 +02001361 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1362 verbose = TRUE) {
1363 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1364 frequency <- NULL
1365
1366 if (nrow(matches) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001367 dplyr::tibble(word = c(), frequency = c())
Marc Kupietz2b17b212023-08-27 17:47:26 +02001368 } else if (index == 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001369 if (!"tokens" %in% colnames(matches) || !is.list(matches$tokens)) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001370 log_info(verbose, "Outdated KorAP server: Falling back to client side tokenization.\n")
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001371 return(snippet2FreqTable(matches$snippet, minOccur, leftContextSize, rightContextSize,
1372 ignoreCollocateCase = ignoreCollocateCase,
1373 stopwords = stopwords, oldTable = oldTable, verbose = verbose
1374 ))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001375 }
1376 log_info(verbose, paste("Joining", nrow(matches), "kwics\n"))
Marc Kupietza25fbd92025-10-14 17:38:09 +02001377 for (i in seq_len(nrow(matches))) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001378 oldTable <- matches2FreqTable(
1379 matches,
1380 i,
1381 leftContextSize = leftContextSize,
1382 rightContextSize = rightContextSize,
1383 collocateFilterRegex = collocateFilterRegex,
1384 oldTable = oldTable,
1385 stopwords = stopwords
1386 )
1387 }
1388 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001389 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001390 group_by(word) |>
1391 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001392 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001393 arrange(desc(frequency))
1394 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001395 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietz2b17b212023-08-27 17:47:26 +02001396
1397 left <- tail(unlist(matches$tokens$left[index]), leftContextSize)
1398
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001399 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001400
1401 right <- head(unlist(matches$tokens$right[index]), rightContextSize)
1402
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001403 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001404
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001405 if (length(left) + length(right) == 0) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001406 oldTable
1407 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001408 table(c(left, right)) |>
1409 dplyr::as_tibble(.name_repair = "minimal") |>
1410 dplyr::rename(word = 1, frequency = 2) |>
1411 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001412 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001413 dplyr::bind_rows(oldTable)
1414 }
1415 }
1416}
1417
1418#' @importFrom magrittr debug_pipe
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001419#' @importFrom stringr str_match str_split str_detect
1420#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1421#'
1422snippet2FreqTable <- function(snippet,
1423 minOccur = 5,
1424 leftContextSize = 5,
1425 rightContextSize = 5,
1426 ignoreCollocateCase = FALSE,
1427 stopwords = c(),
1428 tokenizeRegex = "([! )(\uc2\uab,.:?\u201e\u201c\'\"]+|&quot;)",
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001429 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001430 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1431 verbose = TRUE) {
1432 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1433 frequency <- NULL
1434
1435 if (length(snippet) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001436 dplyr::tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001437 } else if (length(snippet) > 1) {
Marc Kupietza47d1502023-04-18 15:26:47 +02001438 log_info(verbose, paste("Joining", length(snippet), "kwics\n"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001439 for (s in snippet) {
1440 oldTable <- snippet2FreqTable(
1441 s,
1442 leftContextSize = leftContextSize,
1443 rightContextSize = rightContextSize,
Marc Kupietz47d0d2b2021-12-19 16:38:52 +01001444 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001445 oldTable = oldTable,
1446 stopwords = stopwords
1447 )
1448 }
Marc Kupietza47d1502023-04-18 15:26:47 +02001449 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001450 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001451 group_by(word) |>
1452 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001453 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001454 arrange(desc(frequency))
1455 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001456 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001457 match <-
1458 str_match(
1459 snippet,
1460 '<span class="context-left">(<span class="more"></span>)?(.*[^ ]) *</span><span class="match"><mark>.*</mark></span><span class="context-right"> *([^<]*)'
1461 )
1462
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001463 left <- if (leftContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001464 tail(unlist(str_split(match[1, 3], tokenizeRegex)), leftContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001465 } else {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001466 ""
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001467 }
1468 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001469
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001470 right <- if (rightContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001471 head(unlist(str_split(match[1, 4], tokenizeRegex)), rightContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001472 } else {
1473 ""
1474 }
1475 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001476
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001477 if (is.na(left[1]) || is.na(right[1]) || length(left) + length(right) == 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001478 oldTable
1479 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001480 table(c(left, right)) |>
1481 dplyr::as_tibble(.name_repair = "minimal") |>
1482 dplyr::rename(word = 1, frequency = 2) |>
1483 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001484 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001485 dplyr::bind_rows(oldTable)
1486 }
1487 }
1488}
1489
1490#' Preliminary synsemantic stopwords function
1491#'
1492#' @description
Marc Kupietz67edcb52021-09-20 21:54:24 +02001493#' `r lifecycle::badge("experimental")`
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001494#'
1495#' Preliminary synsemantic stopwords function to be used in collocation analysis.
1496#'
1497#' @details
1498#' Currently only suitable for German. See stopwords package for other languages.
1499#'
1500#' @param ... future arguments for language detection
1501#'
1502#' @family collocation analysis functions
1503#' @return Vector of synsemantic stopwords.
1504#' @export
1505synsemanticStopwords <- function(...) {
Marc Kupietzc79155b2025-10-19 13:42:55 +02001506 base <- c(
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001507 "der",
1508 "die",
1509 "und",
1510 "in",
1511 "den",
1512 "von",
1513 "mit",
1514 "das",
1515 "zu",
1516 "im",
1517 "ist",
1518 "auf",
1519 "sich",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001520 "des",
1521 "dem",
1522 "nicht",
1523 "ein",
1524 "eine",
1525 "es",
1526 "auch",
1527 "an",
1528 "als",
1529 "am",
1530 "aus",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001531 "bei",
1532 "er",
1533 "dass",
1534 "sie",
1535 "nach",
1536 "um",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001537 "zum",
1538 "noch",
1539 "war",
1540 "einen",
1541 "einer",
1542 "wie",
1543 "einem",
1544 "vor",
1545 "bis",
1546 "\u00fcber",
1547 "so",
1548 "aber",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001549 "diese",
Marc Kupietzc79155b2025-10-19 13:42:55 +02001550 "oder"
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001551 )
Marc Kupietzc79155b2025-10-19 13:42:55 +02001552
1553 lower <- unique(tolower(base))
1554 capitalized <- paste0(toupper(substr(lower, 1, 1)), substring(lower, 2))
1555
1556 unique(c(lower, capitalized))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001557}
1558
Marc Kupietz5a336b62021-11-27 17:51:35 +01001559
Marc Kupietz76b05592021-12-19 16:26:15 +01001560# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +01001561findExample <-
1562 function(kco,
1563 query,
1564 vc = "",
1565 matchOnly = TRUE) {
1566 out <- character(length = length(query))
1567
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001568 if (length(vc) < length(query)) {
Marc Kupietz5a336b62021-11-27 17:51:35 +01001569 vc <- rep(vc, length(query))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001570 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001571
1572 for (i in seq_along(query)) {
1573 q <- corpusQuery(kco, paste0("(", query[i], ")"), vc = vc[i], metadataOnly = FALSE)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001574 if (q@totalResults > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001575 q <- fetchNext(q, maxFetch = 50, randomizePageOrder = F)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001576 example <- as.character((q@collectedMatches)$snippet[1])
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001577 out[i] <- if (matchOnly) {
1578 gsub(".*<mark>(.+)</mark>.*", "\\1", example)
Marc Kupietz5a336b62021-11-27 17:51:35 +01001579 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001580 stringr::str_replace(example, "<[^>]*>", "")
Marc Kupietz5a336b62021-11-27 17:51:35 +01001581 }
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001582 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001583 out[i] <- ""
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001584 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001585 }
1586 out
1587 }
1588
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001589collocatesQuery <-
1590 function(kco,
1591 query,
1592 vc = "",
1593 minOccur = 5,
1594 leftContextSize = 5,
1595 rightContextSize = 5,
1596 searchHitsSampleLimit = 20000,
1597 ignoreCollocateCase = FALSE,
1598 stopwords = c(),
Marc Kupietzb2862d42025-10-18 10:17:49 +02001599 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001600 ...) {
1601 frequency <- NULL
1602 q <- corpusQuery(kco, query, vc, metadataOnly = F, ...)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001603 if (q@totalResults == 0) {
1604 tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001605 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001606 q <- fetchNext(q, maxFetch = searchHitsSampleLimit, randomizePageOrder = TRUE)
1607 matches2FreqTable(q@collectedMatches,
1608 0,
1609 minOccur = minOccur,
1610 leftContextSize = leftContextSize,
1611 rightContextSize = rightContextSize,
1612 ignoreCollocateCase = ignoreCollocateCase,
1613 stopwords = stopwords,
Marc Kupietzb2862d42025-10-18 10:17:49 +02001614 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001615 ...,
1616 verbose = kco@verbose
1617 ) |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001618 mutate(frequency = frequency * q@totalResults / min(q@totalResults, searchHitsSampleLimit)) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001619 filter(frequency >= minOccur)
1620 }
1621 }