blob: b949e2c40a1ee82f66226a66dfbc8478502a4094 [file] [log] [blame]
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001#' @include logging.R
2setGeneric("collocationAnalysis", function(kco, ...) standardGeneric("collocationAnalysis"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02003
4#' Collocation analysis
5#'
Marc Kupietza8c40f42025-06-24 15:49:52 +02006#' @family collocation analysis functions
Marc Kupietzdbd431a2021-08-29 12:17:45 +02007#' @aliases collocationAnalysis
8#'
9#' @description
Marc Kupietzdbd431a2021-08-29 12:17:45 +020010#'
11#' Performs a collocation analysis for the given node (or query)
12#' in the given virtual corpus.
13#'
14#' @details
15#' The collocation analysis is currently implemented on the client side, as some of the
16#' functionality is not yet provided by the KorAP backend. Mainly for this reason
17#' it is very slow (several minutes, up to hours), but on the other hand very flexible.
18#' You can, for example, perform the analysis in arbitrary virtual corpora, use complex node queries,
19#' and look for expression-internal collocates using the focus function (see examples and demo).
20#'
21#' To increase speed at the cost of accuracy and possible false negatives,
22#' you can decrease searchHitsSampleLimit and/or topCollocatesLimit and/or set exactFrequencies to FALSE.
23#'
Marc Kupietze7f0d682025-02-19 10:50:59 +010024#' Note that some outdated non-DeReKo back-ends might not yet support returning tokenized matches (warning issued).
25#' In this case, the client library will fall back to client-side tokenization which might be slightly less accurate.
26#' This might lead to false negatives and to frequencies that differ from corresponding ones acquired via the web
Marc Kupietzdbd431a2021-08-29 12:17:45 +020027#' user interface.
28#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +020029#'
Marc Kupietz67edcb52021-09-20 21:54:24 +020030#' @param lemmatizeNodeQuery if TRUE, node query will be lemmatized, i.e. `x -> [tt/l=x]`
Marc Kupietzdbd431a2021-08-29 12:17:45 +020031#' @param minOccur minimum absolute number of observed co-occurrences to consider a collocate candidate
32#' @param topCollocatesLimit limit analysis to the n most frequent collocates in the search hits sample
33#' @param searchHitsSampleLimit limit the size of the search hits sample
34#' @param stopwords vector of stopwords not to be considered as collocates
Marc Kupietz6bd9cad2024-12-18 15:57:26 +010035#' @param withinSpan KorAP span specification (see <https://korap.ids-mannheim.de/doc/ql/poliqarp-plus?embedded=true#spans>) for collocations to be searched within. Defaults to `base/s=s`.
Marc Kupietzdbd431a2021-08-29 12:17:45 +020036#' @param exactFrequencies if FALSE, extrapolate observed co-occurrence frequencies from frequencies in search hits sample, otherwise retrieve exact co-occurrence frequencies
37#' @param seed seed for random page collecting order
Marc Kupietz67edcb52021-09-20 21:54:24 +020038#' @param expand if TRUE, `node` and `vc` parameters are expanded to all of their combinations
Marc Kupietz7d400e02021-12-19 16:39:36 +010039#' @param maxRecurse apply collocation analysis recursively `maxRecurse` times
40#' @param addExamples If TRUE, examples for instances of collocations will be added in a column `example`. This makes a difference in particular if `node` is given as a lemma query.
Marc Kupietz2b0b0a12025-10-19 14:49:14 +020041#' @param thresholdScore association score function (see \code{\link{association-score-functions}}) to use for computing the threshold that is applied for recursive collocation analysis calls (only applied when \code{maxRecurse > 0})
42#' @param threshold minimum value of `thresholdScore` function call to apply collocation analysis recursively (only applied when \code{maxRecurse > 0})
Marc Kupietz7d400e02021-12-19 16:39:36 +010043#' @param localStopwords vector of stopwords that will not be considered as collocates in the current function call, but that will not be passed to recursive calls
Marc Kupietz47d0d2b2021-12-19 16:38:52 +010044#' @param collocateFilterRegex allow only collocates matching the regular expression
Marc Kupietzde679ea2025-10-19 13:14:51 +020045#' @param queryMissingScores if TRUE, attempt to retrieve corpus-based association scores for vc/collocate combinations that would otherwise be imputed, by re-querying the KorAP backend without applying the collocate frequency threshold
Marc Kupietz95253342026-08-31 10:18:43 +020046#' @param missingScoreQuantile lower quantile (evaluated per association measure over the pooled result set) that anchors the adaptive floor used for imputing missing scores between virtual corpora; a robust spread is subtracted from this anchor so the imputed values stay at or below the weakest observed scores. Imputed cells are marked in the `imputed*` columns; see the section on interpreting multi-VC comparisons below
Marc Kupietze34a8be2025-10-17 20:13:42 +020047#' @param vcLabel optional label override for the current virtual corpus (used internally when named VC collections are expanded)
Marc Kupietzdb2fabd2026-04-27 15:01:37 +020048#' @param cacheAs path to an RDS file for caching the result. If the file already exists, the cached result is loaded and returned immediately without contacting the server. Otherwise the analysis is run normally and the result is saved to the file before returning. Defaults to \code{NULL} (no caching).
Marc Kupietz67edcb52021-09-20 21:54:24 +020049#' @param ... more arguments will be passed to [collocationScoreQuery()]
Marc Kupietzdbd431a2021-08-29 12:17:45 +020050#' @inheritParams collocationScoreQuery,KorAPConnection-method
Marc Kupietz130a2a22025-10-18 16:09:23 +020051#' @return
52#' A tibble where each row represents a candidate collocate for the requested node.
53#' Columns include (depending on the selected association measures):
Marc Kupietzdb2fabd2026-04-27 15:01:37 +020054#'
Marc Kupietz130a2a22025-10-18 16:09:23 +020055#' \itemize{
56#' \item \code{node}, \code{collocate}, \code{vc}, \code{label}: identifiers for the query node, collocate, virtual corpus, and optional label.
57#' \item Frequency and contingency information such as \code{frequency}, \code{O}, \code{O1}, \code{O2}, \code{E}, \code{leftContextSize}, \code{rightContextSize}, and \code{w}.
58#' \item Association measures (e.g. \code{logDice}, \code{ll}, \code{mi}, ...), one column per requested scorer.
59#' \item Per-labelled association scores produced by multi-VC comparisons using the pattern \code{<measure>_<label>}.
60#' \item Ranks per label/measure with the pattern \code{rank_<label>_<measure>} (1 is best) and the corresponding percentile ranks \code{percentile_rank_<label>_<measure>}.
61#' \item Pairwise contrasts for two-label comparisons, e.g. \code{delta_<measure>}, \code{delta_rank_<measure>}, and \code{delta_percentile_rank_<measure>}.
Marc Kupietz09b1c082026-05-01 14:45:47 +020062#' \item Summary columns describing the strongest labels per measure (\code{winner_*}, \code{runner_up_*}, \code{loser_*}, and \code{max_delta_*}), including winner/loser \code{webUIRequestUrl} columns. In multi-VC comparisons, missing per-label concordance URLs are derived from another available row URL for the same \code{node}/\code{collocate} by replacing the \code{cq} parameter with the target label's virtual corpus. Unsuffixed \code{winner_webUIRequestUrl} and \code{loser_webUIRequestUrl} columns are populated only when the score-based URL choices agree.
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +020063#' \item \code{imputed_<label>}, \code{n_imputed}, and \code{imputed}: flags marking rows whose scores were not observed for some label but imputed (see \code{missingScoreQuantile}). Filter with \code{dplyr::filter(!imputed)} to keep only collocates attested in every compared virtual corpus.
Marc Kupietz130a2a22025-10-18 16:09:23 +020064#' \item Optional helper columns such as \code{query}, \code{example}, or \code{url} when example retrieval is requested.
65#' }
Marc Kupietz95253342026-08-31 10:18:43 +020066#' @section Interpreting multi-VC comparisons:
67#'
68#' The comparison columns are an exploration aid, not a significance test. When
69#' reading them, keep three properties in mind.
70#'
71#' \strong{Imputed scores describe presence/absence, not contrast.} A collocate
72#' that passes the `minOccur` and `topCollocatesLimit` thresholds in one virtual
73#' corpus but not in another has no observed score for the latter. Such cells are
74#' imputed from a floor derived from the pooled result set (see
75#' `missingScoreQuantile`), so the corresponding `delta_*` and `max_delta_*`
76#' values measure the distance to that floor rather than an attested difference.
77#' The `imputed`, `n_imputed` and `imputed_<label>` columns mark these rows;
78#' `dplyr::filter(!imputed)` restricts the result to collocates attested
79#' everywhere, and `queryMissingScores = TRUE` replaces most imputed cells with
80#' scores actually retrieved from the backend.
81#'
82#' \strong{Imputed values are relative to one analysis.} The floor is computed
83#' from the scores present in the result at hand. Analysing a node on its own and
84#' analysing it together with other nodes therefore yield different imputed
85#' values, and deltas involving imputed cells are not comparable across separate
86#' calls. Deltas between observed scores are unaffected.
87#'
88#' \strong{Winners carry no uncertainty.} Unlike [ci()], which attaches
89#' confidence intervals to relative frequencies, the `winner_*` / `loser_*`
90#' columns simply order point estimates. A collocate wins by a hair on six
91#' occurrences exactly as decisively as one that wins by a wide margin on
92#' thousands. Consult the observed frequencies (`O`, `O1`, `O2`) and the
93#' `webUIRequestUrl` concordance links before drawing conclusions from a
94#' small difference.
95#'
96#' Note also that `rank_<label>_<measure>` and
97#' `percentile_rank_<label>_<measure>` are computed within each label, over that
98#' label's own candidate set. Candidate sets usually differ in size between
99#' virtual corpora, so rank-based deltas compare positions in populations of
100#' different sizes.
Marc Kupietzc4540a22025-10-14 17:39:53 +0200101#' @importFrom dplyr arrange desc slice_head bind_rows group_by mutate ungroup left_join select row_number all_of first
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200102#' @importFrom purrr pmap
Marc Kupietzc4540a22025-10-14 17:39:53 +0200103#' @importFrom tidyr expand_grid pivot_wider
104#' @importFrom rlang sym
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200105#'
106#' @examples
Marc Kupietz6ae76052021-09-21 10:34:00 +0200107#' \dontrun{
108#'
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200109#' # Find top collocates of "Packung" inside and outside the sports domain.
110#' KorAPConnection(verbose = TRUE) |>
111#' collocationAnalysis("Packung",
112#' vc = c("textClass=sport", "textClass!=sport"),
113#' leftContextSize = 1, rightContextSize = 1, topCollocatesLimit = 20
114#' ) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200115#' dplyr::filter(logDice >= 5)
116#' }
117#'
Marc Kupietz6ae76052021-09-21 10:34:00 +0200118#' \dontrun{
119#'
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200120#' # Identify the most prominent light verb construction with "in ... setzen".
121#' # Note that, currently, the use of focus function disallows exactFrequencies.
Marc Kupietz4cd066d2025-02-28 15:48:23 +0100122#' KorAPConnection(verbose = TRUE) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200123#' collocationAnalysis("focus(in [tt/p=NN] {[tt/l=setzen]})",
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200124#' leftContextSize = 1, rightContextSize = 0, exactFrequencies = FALSE, topCollocatesLimit = 20
125#' )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200126#' }
127#'
128#' @export
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200129setMethod(
130 "collocationAnalysis", "KorAPConnection",
131 function(kco,
132 node,
133 vc = "",
134 lemmatizeNodeQuery = FALSE,
135 minOccur = 5,
136 leftContextSize = 5,
137 rightContextSize = 5,
138 topCollocatesLimit = 200,
139 searchHitsSampleLimit = 20000,
140 ignoreCollocateCase = FALSE,
141 withinSpan = ifelse(exactFrequencies, "base/s=s", ""),
142 exactFrequencies = TRUE,
143 stopwords = append(RKorAPClient::synsemanticStopwords(), node),
144 seed = 7,
145 expand = length(vc) != length(node),
146 maxRecurse = 0,
147 addExamples = FALSE,
148 thresholdScore = "logDice",
149 threshold = 2.0,
150 localStopwords = c(),
151 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzde679ea2025-10-19 13:14:51 +0200152 queryMissingScores = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200153 missingScoreQuantile = 0.05,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200154 vcLabel = NA_character_,
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200155 cacheAs = NULL,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200156 ...) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200157 word <- frequency <- O <- NULL
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200158
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200159 if (!is.null(cacheAs) && !grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
160 cacheAs <- paste0(cacheAs, ".rds")
161 }
162
163 if (!is.null(cacheAs) && file.exists(cacheAs)) {
164 log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
165 return(readRDS(cacheAs))
166 }
167
Marc Kupietzb2862d42025-10-18 10:17:49 +0200168 if (!exactFrequencies && (!is.na(withinSpan) && !is.null(withinSpan) && nzchar(withinSpan))) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200169 stop(sprintf("Not empty withinSpan (='%s') requires exactFrequencies=TRUE", withinSpan), call. = FALSE)
170 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200171
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200172 warnIfNotAuthorized(kco)
Marc Kupietz581a29b2021-09-04 20:51:04 +0200173
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200174 if (lemmatizeNodeQuery) {
175 node <- lemmatizeWordQuery(node)
176 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200177
Marc Kupietze34a8be2025-10-17 20:13:42 +0200178 vcNames <- names(vc)
Marc Kupietze34a8be2025-10-17 20:13:42 +0200179 if (is.null(vcNames)) {
180 vcNames <- rep(NA_character_, length(vc))
Marc Kupietze34a8be2025-10-17 20:13:42 +0200181 }
182
183 label_lookup <- NULL
Marc Kupietzb2862d42025-10-18 10:17:49 +0200184 if (!is.null(names(vc)) && length(vc) > 0) {
185 raw_names <- names(vc)
186 if (any(!is.na(raw_names) & raw_names != "")) {
187 label_lookup <- stats::setNames(raw_names, vc)
188 }
Marc Kupietze34a8be2025-10-17 20:13:42 +0200189 }
190
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200191 result <- if (length(node) > 1 || length(vc) > 1) {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200192 grid <- if (expand) {
Marc Kupietzb2862d42025-10-18 10:17:49 +0200193 tmp_grid <- tidyr::expand_grid(node = node, idx = seq_along(vc))
194 tmp_grid$vc <- vc[tmp_grid$idx]
195 tmp_grid$vcLabel <- vcNames[tmp_grid$idx]
196 tmp_grid[, c("node", "vc", "vcLabel"), drop = FALSE]
Marc Kupietze34a8be2025-10-17 20:13:42 +0200197 } else {
198 tibble(node = node, vc = vc, vcLabel = vcNames)
199 }
200
201 multi_result <- purrr::pmap(grid, function(node, vc, vcLabel, ...) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200202 collocationAnalysis(kco,
203 node = node,
204 vc = vc,
205 minOccur = minOccur,
206 leftContextSize = leftContextSize,
207 rightContextSize = rightContextSize,
208 topCollocatesLimit = topCollocatesLimit,
209 searchHitsSampleLimit = searchHitsSampleLimit,
210 ignoreCollocateCase = ignoreCollocateCase,
211 withinSpan = withinSpan,
212 exactFrequencies = exactFrequencies,
213 stopwords = stopwords,
214 addExamples = TRUE,
215 localStopwords = localStopwords,
216 seed = seed,
217 expand = expand,
Marc Kupietz9894a372025-10-18 14:51:29 +0200218 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200219 queryMissingScores = queryMissingScores,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200220 collocateFilterRegex = collocateFilterRegex,
Marc Kupietze34a8be2025-10-17 20:13:42 +0200221 vcLabel = vcLabel,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200222 ...
223 )
224 }) |>
Marc Kupietze31322e2025-10-17 18:55:36 +0200225 bind_rows()
226
227 if (!"vc" %in% names(multi_result) || nrow(multi_result) == 0) {
228 multi_result
229 } else {
Marc Kupietzde679ea2025-10-19 13:14:51 +0200230 if (queryMissingScores) {
231 multi_result <- backfill_missing_scores(
232 multi_result,
233 grid = grid,
234 kco = kco,
235 ignoreCollocateCase = ignoreCollocateCase,
236 ...
237 )
238 }
239
Marc Kupietze34a8be2025-10-17 20:13:42 +0200240 if (!"label" %in% names(multi_result)) {
241 multi_result$label <- NA_character_
242 }
243
244 if (!is.null(label_lookup)) {
245 override <- unname(label_lookup[multi_result$vc])
246 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
247 if (any(missing_idx)) {
248 multi_result$label[missing_idx] <- override[missing_idx]
249 }
250 }
251
252 missing_idx <- is.na(multi_result$label) | multi_result$label == ""
253 if (any(missing_idx)) {
254 multi_result$label[missing_idx] <- queryStringToLabel(multi_result$vc[missing_idx])
255 }
256
Marc Kupietze31322e2025-10-17 18:55:36 +0200257 multi_result |>
Marc Kupietz9894a372025-10-18 14:51:29 +0200258 add_multi_vc_comparisons(
Marc Kupietz424cb782026-08-31 10:19:29 +0200259 missingScoreQuantile = missingScoreQuantile,
260 verbose = kco@verbose
Marc Kupietz9894a372025-10-18 14:51:29 +0200261 )
Marc Kupietze31322e2025-10-17 18:55:36 +0200262 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200263 } else {
Marc Kupietze34a8be2025-10-17 20:13:42 +0200264 if ((is.na(vcLabel) || vcLabel == "") && length(vcNames) >= 1) {
265 vcLabel <- vcNames[1]
266 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200267
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200268 set.seed(seed)
269 candidates <- collocatesQuery(
270 kco,
271 node,
272 vc = vc,
273 minOccur = minOccur,
274 leftContextSize = leftContextSize,
275 rightContextSize = rightContextSize,
276 searchHitsSampleLimit = searchHitsSampleLimit,
277 ignoreCollocateCase = ignoreCollocateCase,
278 stopwords = append(stopwords, localStopwords),
Marc Kupietzb2862d42025-10-18 10:17:49 +0200279 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200280 ...
281 )
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200282
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200283 if (nrow(candidates) > 0) {
284 candidates <- candidates |>
285 filter(frequency >= minOccur) |>
286 slice_head(n = topCollocatesLimit)
287 collocationScoreQuery(
288 kco,
289 node = node,
290 collocate = candidates$word,
291 vc = vc,
292 leftContextSize = leftContextSize,
293 rightContextSize = rightContextSize,
294 observed = if (exactFrequencies) NA else candidates$frequency,
295 ignoreCollocateCase = ignoreCollocateCase,
296 withinSpan = withinSpan,
297 ...
298 ) |>
299 filter(O >= minOccur) |>
300 dplyr::arrange(dplyr::desc(logDice))
301 } else {
302 tibble()
303 }
304 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200305
306 if (!is.na(vcLabel) && vcLabel != "" && "label" %in% names(result)) {
307 result$label <- rep(vcLabel, nrow(result))
308 }
309
310 threshold_col <- thresholdScore
311 if (maxRecurse > 0 && nrow(result) > 0 && threshold_col %in% names(result)) {
312 threshold_values <- result[[threshold_col]]
313 eligible_idx <- which(!is.na(threshold_values) & threshold_values >= threshold)
314 if (length(eligible_idx) > 0) {
315 recurseWith <- result[eligible_idx, , drop = FALSE]
316 result <- collocationAnalysis(
317 kco,
318 node = paste0("(", buildCollocationQuery(
319 removeWithinSpan(recurseWith$node, withinSpan),
320 recurseWith$collocate,
321 leftContextSize = leftContextSize,
322 rightContextSize = rightContextSize,
323 withinSpan = ""
324 ), ")"),
325 vc = vc,
326 minOccur = minOccur,
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200327 leftContextSize = leftContextSize,
328 rightContextSize = rightContextSize,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200329 withinSpan = withinSpan,
330 maxRecurse = maxRecurse - 1,
331 stopwords = stopwords,
332 localStopwords = recurseWith$collocate,
333 exactFrequencies = exactFrequencies,
334 searchHitsSampleLimit = searchHitsSampleLimit,
335 topCollocatesLimit = topCollocatesLimit,
336 addExamples = FALSE,
Marc Kupietz9894a372025-10-18 14:51:29 +0200337 missingScoreQuantile = missingScoreQuantile,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200338 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzde679ea2025-10-19 13:14:51 +0200339 queryMissingScores = queryMissingScores,
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200340 thresholdScore = thresholdScore,
341 threshold = threshold,
Marc Kupietzb2862d42025-10-18 10:17:49 +0200342 vcLabel = vcLabel
343 ) |>
Marc Kupietz2b0b0a12025-10-19 14:49:14 +0200344 bind_rows(result)
345
346 if (threshold_col %in% names(result)) {
347 threshold_values <- result[[threshold_col]]
348 keep_idx <- is.na(threshold_values) | threshold_values >= threshold
349 result <- result[keep_idx, , drop = FALSE]
350 }
351
352 result <- result |>
Marc Kupietzb2862d42025-10-18 10:17:49 +0200353 filter(O >= minOccur) |>
354 dplyr::arrange(dplyr::desc(logDice))
355 }
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200356 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200357
358 if (addExamples && nrow(result) > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200359 result$query <- buildCollocationQuery(
360 result$node,
361 result$collocate,
362 leftContextSize = leftContextSize,
363 rightContextSize = rightContextSize,
364 withinSpan = withinSpan
365 )
366 result$example <- findExample(
367 kco,
368 query = result$query,
369 vc = result$vc
370 )
371 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200372
Marc Kupietz0a292632025-10-19 14:04:36 +0200373 if (!is.null(withinSpan) && !is.na(withinSpan) && nzchar(withinSpan) &&
374 nrow(result) > 0 &&
375 "webUIRequestUrl" %in% names(result) &&
376 "query" %in% names(result)) {
377 candidate_rows <- which(!is.na(result$node) &
378 !grepl("focus\\(", result$node, perl = TRUE) &
379 !is.na(result$query) & nzchar(result$query))
380
381 if (length(candidate_rows) > 0) {
382 focused_queries <- vapply(
383 result$query[candidate_rows],
384 inject_focus_into_query,
385 character(1)
386 )
387
388 changed <- focused_queries != result$query[candidate_rows]
389 if (any(changed)) {
390 indices <- candidate_rows[changed]
391 vc_values <- as.character(result$vc)
392 vc_values[is.na(vc_values)] <- ""
393
394 result$webUIRequestUrl[indices] <- mapply(
395 function(new_query, vc_value) {
396 buildWebUIRequestUrlFromString(
397 kco@KorAPUrl,
398 new_query,
399 vc = vc_value,
400 ql = "poliqarp"
401 )
402 },
403 focused_queries[changed],
404 vc_values[indices],
405 USE.NAMES = FALSE
406 )
407 }
408 }
409 }
410
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200411 if (!is.null(cacheAs)) {
412 log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", cacheAs))
413 saveRDS(result, cacheAs)
414 }
415
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200416 result
417 }
Marc Kupietzdbd431a2021-08-29 12:17:45 +0200418)
419
Marc Kupietz76b05592021-12-19 16:26:15 +0100420# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +0100421removeWithinSpan <- function(query, withinSpan) {
422 if (withinSpan == "") {
423 return(query)
424 }
425 needle <- sprintf("^\\(contains\\(<%s>, ?(.*)\\){2}$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200426 res <- gsub(needle, "\\1", query)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100427 needle <- sprintf("^contains\\(<%s>, ?(.*)\\)$", withinSpan)
Marc Kupietz6dfeed92025-06-03 11:58:06 +0200428 res <- gsub(needle, "\\1", res)
Marc Kupietz5a336b62021-11-27 17:51:35 +0100429 return(res)
430}
431
Marc Kupietzde679ea2025-10-19 13:14:51 +0200432backfill_missing_scores <- function(result,
433 grid,
434 kco,
435 ignoreCollocateCase,
436 ...) {
437 if (!"vc" %in% names(result) || !"node" %in% names(result) || !"collocate" %in% names(result)) {
438 return(result)
439 }
440
441 if (nrow(result) == 0) {
442 return(result)
443 }
444
Marc Kupietz9c53e412026-06-21 12:13:44 +0200445 distinct_pairs <- dplyr::distinct(
446 result,
447 .data$node,
448 .data$collocate
449 )
Marc Kupietzde679ea2025-10-19 13:14:51 +0200450 if (nrow(distinct_pairs) == 0) {
451 return(result)
452 }
453
454 collocates_by_node <- split(as.character(distinct_pairs$collocate), distinct_pairs$node)
455 if (length(collocates_by_node) == 0) {
456 return(result)
457 }
458
459 required_combinations <- unique(as.data.frame(grid[, c("node", "vc", "vcLabel")], drop = FALSE))
460 for (i in seq_len(nrow(required_combinations))) {
461 node_value <- required_combinations$node[i]
462 vc_value <- required_combinations$vc[i]
463
464 collocate_pool <- collocates_by_node[[node_value]]
465 if (is.null(collocate_pool) || length(collocate_pool) == 0) {
466 next
467 }
468
469 existing_idx <- result$node == node_value & result$vc == vc_value
470 existing_collocates <- unique(as.character(result$collocate[existing_idx]))
471 missing_collocates <- setdiff(unique(collocate_pool), existing_collocates)
472 missing_collocates <- missing_collocates[!is.na(missing_collocates) & nzchar(missing_collocates)]
473
474 if (length(missing_collocates) == 0) {
475 next
476 }
477
478 context_rows <- result[result$node == node_value & result$vc == vc_value, , drop = FALSE]
479 if (nrow(context_rows) == 0) {
480 context_rows <- result[result$node == node_value, , drop = FALSE]
481 }
482
483 left_size <- context_rows$leftContextSize[!is.na(context_rows$leftContextSize)][1]
484 if (is.na(left_size) || length(left_size) == 0) {
485 left_size <- result$leftContextSize[!is.na(result$leftContextSize)][1]
486 }
487 if (is.na(left_size) || length(left_size) == 0) {
488 left_size <- 5
489 }
490
491 right_size <- context_rows$rightContextSize[!is.na(context_rows$rightContextSize)][1]
492 if (is.na(right_size) || length(right_size) == 0) {
493 right_size <- result$rightContextSize[!is.na(result$rightContextSize)][1]
494 }
495 if (is.na(right_size) || length(right_size) == 0) {
496 right_size <- 5
497 }
498
499 within_span_value <- ""
500 if ("query" %in% names(context_rows)) {
501 query_candidate <- context_rows$query[!is.na(context_rows$query) & nzchar(context_rows$query)][1]
502 if (!is.na(query_candidate) && nzchar(query_candidate)) {
503 match_one <- regexec("^\\(*contains\\(<([^>]+)>,", query_candidate)
504 matches <- regmatches(query_candidate, match_one)
505 if (length(matches) >= 1 && length(matches[[1]]) >= 2) {
506 within_span_value <- matches[[1]][2]
507 }
508 }
509 }
510
511 new_rows <- collocationScoreQuery(
512 kco,
513 node = node_value,
514 collocate = missing_collocates,
515 vc = vc_value,
516 leftContextSize = left_size,
517 rightContextSize = right_size,
518 ignoreCollocateCase = ignoreCollocateCase,
519 withinSpan = within_span_value,
520 ...
521 )
522
523 if (nrow(new_rows) == 0) {
524 next
525 }
526
527 if (!is.null(required_combinations$vcLabel[i]) && !is.na(required_combinations$vcLabel[i]) && required_combinations$vcLabel[i] != "" && "label" %in% names(new_rows)) {
528 new_rows$label <- required_combinations$vcLabel[i]
529 }
530
531 result <- dplyr::bind_rows(result, new_rows)
532 }
533
534 result
535}
536
Marc Kupietz0a292632025-10-19 14:04:36 +0200537inject_focus_into_query <- function(query) {
538 if (is.null(query) || is.na(query)) {
539 return(query)
540 }
541
542 trimmed <- trimws(query)
543 if (!nzchar(trimmed)) {
544 return(query)
545 }
546
547 if (!grepl("^contains\\(<[^>]+>", trimmed, perl = TRUE)) {
548 return(query)
549 }
550
551 if (grepl("focus\\(", trimmed, perl = TRUE)) {
552 return(query)
553 }
554
555 pattern <- "^contains\\(<([^>]+)>\\s*,\\s*\\((.*)\\)\\)\\s*$"
556 matches <- regexec(pattern, trimmed, perl = TRUE)
557 components <- regmatches(trimmed, matches)
558 if (length(components) == 0 || length(components[[1]]) < 3) {
559 return(query)
560 }
561
562 span <- components[[1]][2]
563 inner <- components[[1]][3]
564 parts <- strsplit(inner, "\\|", perl = TRUE)[[1]]
565 parts <- trimws(parts)
566 parts <- parts[nzchar(parts)]
567
568 if (length(parts) == 0) {
569 return(query)
570 }
571
572 focused <- paste0("focus({", parts, "})")
573 combined <- paste(focused, collapse = " | ")
574
575 sprintf("contains(<%s>, (%s))", span, combined)
576}
577
Marc Kupietz424cb782026-08-31 10:19:29 +0200578add_multi_vc_comparisons <- function(result, missingScoreQuantile = 0.05, verbose = FALSE) {
Marc Kupietz09b1c082026-05-01 14:45:47 +0200579 label <- node <- collocate <- vc <- webUIRequestUrl <- NULL
Marc Kupietzc4540a22025-10-14 17:39:53 +0200580
581 if (!"label" %in% names(result) || dplyr::n_distinct(result$label) < 2) {
582 return(result)
583 }
584
585 numeric_cols <- names(result)[vapply(result, is.numeric, logical(1))]
586 non_score_cols <- c("N", "O", "O1", "O2", "E", "w", "leftContextSize", "rightContextSize", "frequency")
587 score_cols <- setdiff(numeric_cols, non_score_cols)
588
589 if (length(score_cols) == 0) {
590 return(result)
591 }
592
Marc Kupietz9894a372025-10-18 14:51:29 +0200593 compute_score_floor <- function(values) {
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200594 # Estimate a conservative floor so missing scores can be imputed without favoring any label
Marc Kupietz9894a372025-10-18 14:51:29 +0200595 finite_values <- values[is.finite(values)]
596 if (length(finite_values) == 0) {
597 return(0)
598 }
599
600 prob <- min(max(missingScoreQuantile, 0), 0.5)
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200601 # Use a lower quantile as the anchor to stay near the weakest attested scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200602 q_val <- suppressWarnings(stats::quantile(finite_values,
603 probs = prob,
604 names = FALSE,
605 type = 7
606 ))
607
608 if (!is.finite(q_val)) {
609 q_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
610 }
611
612 min_val <- suppressWarnings(min(finite_values, na.rm = TRUE))
613 if (!is.finite(min_val)) {
614 min_val <- 0
615 }
616
617 spread_candidates <- c(
618 suppressWarnings(stats::IQR(finite_values, na.rm = TRUE, type = 7)),
619 stats::sd(finite_values, na.rm = TRUE),
620 abs(q_val) * 0.1,
621 abs(min_val - q_val)
622 )
623 spread_candidates <- spread_candidates[is.finite(spread_candidates)]
624
625 spread <- 0
626 if (length(spread_candidates) > 0) {
627 spread <- max(spread_candidates)
628 }
629 if (!is.finite(spread) || spread == 0) {
630 spread <- max(abs(q_val), abs(min_val), 1e-06)
631 }
632
Marc Kupietz4cbb5472025-10-19 12:15:25 +0200633 # Step away from the anchor by a robust spread estimate to avoid ties with real scores
Marc Kupietz9894a372025-10-18 14:51:29 +0200634 candidate <- q_val - spread
635 if (!is.finite(candidate)) {
636 candidate <- min_val
637 }
638
639 floor_value <- suppressWarnings(min(c(candidate, min_val), na.rm = TRUE))
640 if (!is.finite(floor_value)) {
641 floor_value <- min_val
642 }
643 if (!is.finite(floor_value)) {
644 floor_value <- 0
645 }
646
647 floor_value
648 }
649
650 score_replacements <- stats::setNames(
651 vapply(score_cols, function(col) {
652 compute_score_floor(result[[col]])
653 }, numeric(1)),
654 score_cols
655 )
656
Marc Kupietzc4540a22025-10-14 17:39:53 +0200657 comparison <- result |>
Marc Kupietz28a29842025-10-18 12:25:09 +0200658 dplyr::select(node, collocate, label, dplyr::all_of(score_cols)) |>
659 tidyr::pivot_wider(
Marc Kupietzc4540a22025-10-14 17:39:53 +0200660 names_from = label,
Marc Kupietz28a29842025-10-18 12:25:09 +0200661 values_from = dplyr::all_of(score_cols),
Marc Kupietzc4540a22025-10-14 17:39:53 +0200662 names_glue = "{.value}_{make.names(label)}",
663 values_fn = dplyr::first
664 )
665
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200666 raw_labels <- unique(result$label)
667 labels <- make.names(raw_labels)
668 label_map <- stats::setNames(raw_labels, labels)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200669 vc_map <- result |>
670 dplyr::select(label, vc) |>
671 dplyr::filter(!is.na(label), label != "") |>
672 dplyr::distinct(label, .keep_all = TRUE)
673 vc_map <- stats::setNames(vc_map$vc, make.names(vc_map$label))
674
675 replace_web_ui_cq <- function(url, vc_value) {
676 if (length(url) == 0 || is.na(url) || url == "") {
677 return(NA_character_)
678 }
679 if (length(vc_value) == 0 || is.na(vc_value)) {
680 vc_value <- ""
681 }
682 encoded_vc <- urltools::url_encode(enc2utf8(as.character(vc_value)))
683 if (grepl("([?&]cq=)[^&]*", url, perl = TRUE)) {
684 return(sub("([?&]cq=)[^&]*", paste0("\\1", encoded_vc), url, perl = TRUE))
685 }
686 if (encoded_vc == "") {
687 return(url)
688 }
689 paste0(url, ifelse(grepl("\\?", url), "&", "?"), "cq=", encoded_vc)
690 }
691
692 if ("webUIRequestUrl" %in% names(result)) {
693 url_data <- result |>
694 dplyr::select(node, collocate, label, webUIRequestUrl) |>
695 tidyr::pivot_wider(
696 names_from = label,
697 values_from = webUIRequestUrl,
698 names_glue = "webUIRequestUrl_{make.names(label)}",
699 values_fn = dplyr::first
700 )
701
702 comparison <- dplyr::left_join(comparison, url_data, by = c("node", "collocate"))
703
704 url_cols <- paste0("webUIRequestUrl_", labels)
705 present_url_cols <- intersect(url_cols, names(comparison))
706 fallback_urls <- vapply(seq_len(nrow(comparison)), function(i) {
707 urls <- unlist(comparison[i, present_url_cols, drop = FALSE], use.names = FALSE)
708 urls <- as.character(urls)
709 urls <- urls[!is.na(urls) & urls != ""]
710 if (length(urls) == 0) {
711 NA_character_
712 } else {
713 urls[1]
714 }
715 }, character(1))
716
717 for (safe_label in labels) {
718 url_col <- paste0("webUIRequestUrl_", safe_label)
719 if (!url_col %in% names(comparison)) {
720 comparison[[url_col]] <- NA_character_
721 }
722 missing_urls <- is.na(comparison[[url_col]]) | comparison[[url_col]] == ""
723 if (any(missing_urls)) {
724 comparison[[url_col]][missing_urls] <- vapply(
725 fallback_urls[missing_urls],
726 replace_web_ui_cq,
727 character(1),
728 vc_value = vc_map[[safe_label]]
729 )
730 }
731 }
732 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200733
Marc Kupietz28a29842025-10-18 12:25:09 +0200734 rank_data <- result |>
735 dplyr::distinct(node, collocate)
736
737 for (i in seq_along(raw_labels)) {
738 raw_lab <- raw_labels[i]
739 safe_lab <- labels[i]
740 label_df <- result[result$label == raw_lab, c("node", "collocate", score_cols), drop = FALSE]
741 if (nrow(label_df) == 0) {
742 next
743 }
744 label_df <- dplyr::distinct(label_df)
745 rank_tbl <- label_df[, c("node", "collocate"), drop = FALSE]
746 for (col in score_cols) {
747 rank_col_name <- paste0("rank_", safe_lab, "_", col)
Marc Kupietz130a2a22025-10-18 16:09:23 +0200748 percentile_col_name <- paste0("percentile_rank_", safe_lab, "_", col)
Marc Kupietz28a29842025-10-18 12:25:09 +0200749 values <- label_df[[col]]
750 ranks <- rep(NA_real_, length(values))
Marc Kupietz130a2a22025-10-18 16:09:23 +0200751 percentiles <- rep(NA_real_, length(values))
Marc Kupietz28a29842025-10-18 12:25:09 +0200752 valid_idx <- which(!is.na(values))
753 if (length(valid_idx) > 0) {
754 ranks[valid_idx] <- rank(-values[valid_idx], ties.method = "first")
Marc Kupietz130a2a22025-10-18 16:09:23 +0200755 total <- length(valid_idx)
756 percentiles[valid_idx] <- 1 - (ranks[valid_idx] - 1) / total
Marc Kupietz28a29842025-10-18 12:25:09 +0200757 }
758 rank_tbl[[rank_col_name]] <- ranks
Marc Kupietz130a2a22025-10-18 16:09:23 +0200759 rank_tbl[[percentile_col_name]] <- percentiles
Marc Kupietz28a29842025-10-18 12:25:09 +0200760 }
761 rank_data <- dplyr::left_join(rank_data, rank_tbl, by = c("node", "collocate"))
762 }
763
764 comparison <- dplyr::left_join(comparison, rank_data, by = c("node", "collocate"))
765
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +0200766 # Record which label/measure cells are absent *before* any imputation happens below.
767 # Deltas computed from imputed cells reflect presence/absence of the collocate in a
768 # virtual corpus, not a measured contrast, so users need to be able to tell them apart.
769 imputed_flags <- lapply(labels, function(safe_label) {
770 label_score_cols <- intersect(paste0(score_cols, "_", safe_label), names(comparison))
771 if (length(label_score_cols) == 0) {
772 return(rep(FALSE, nrow(comparison)))
773 }
774 Reduce(`|`, lapply(label_score_cols, function(col) is.na(comparison[[col]])))
775 })
776 names(imputed_flags) <- paste0("imputed_", labels)
777
Marc Kupietz28a29842025-10-18 12:25:09 +0200778 rank_replacements <- numeric(0)
779 rank_column_names <- grep("^rank_", names(comparison), value = TRUE)
780 if (length(rank_column_names) > 0) {
781 rank_replacements <- stats::setNames(
782 vapply(rank_column_names, function(col) {
783 col_values <- comparison[[col]]
784 valid_values <- col_values[!is.na(col_values)]
785 if (length(valid_values) == 0) {
786 nrow(comparison) + 1
787 } else {
788 suppressWarnings(max(valid_values, na.rm = TRUE)) + 1
789 }
790 }, numeric(1)),
791 rank_column_names
792 )
793 }
794
Marc Kupietz130a2a22025-10-18 16:09:23 +0200795 percentile_replacements <- numeric(0)
796 percentile_column_names <- grep("^percentile_rank_", names(comparison), value = TRUE)
797 if (length(percentile_column_names) > 0) {
798 percentile_replacements <- stats::setNames(
799 rep(0, length(percentile_column_names)),
800 percentile_column_names
801 )
802 }
803
Marc Kupietz28a29842025-10-18 12:25:09 +0200804 collapse_label_values <- function(indices, safe_labels_vec) {
805 if (length(indices) == 0) {
806 return(NA_character_)
807 }
808 labs <- label_map[safe_labels_vec[indices]]
809 fallback <- safe_labels_vec[indices]
810 labs[is.na(labs) | labs == ""] <- fallback[is.na(labs) | labs == ""]
811 labs <- labs[!is.na(labs) & labs != ""]
812 if (length(labs) == 0) {
813 return(NA_character_)
814 }
815 paste(unique(labs), collapse = ", ")
816 }
817
Marc Kupietz09b1c082026-05-01 14:45:47 +0200818 collapse_url_values <- function(indices, url_values) {
819 if (length(indices) == 0 || is.null(url_values)) {
820 return(NA_character_)
821 }
822 urls <- as.character(url_values[indices])
823 urls <- urls[!is.na(urls) & urls != ""]
824 if (length(urls) == 0) {
825 return(NA_character_)
826 }
827 paste(unique(urls), collapse = ", ")
828 }
829
Marc Kupietzc4540a22025-10-14 17:39:53 +0200830 if (length(labels) == 2) {
Marc Kupietz9894a372025-10-18 14:51:29 +0200831 fill_scores <- function(x, y, measure_col) {
832 replacement <- score_replacements[[measure_col]]
833 fallback_min <- suppressWarnings(min(c(x, y), na.rm = TRUE))
834 if (!is.finite(fallback_min)) {
835 fallback_min <- 0
Marc Kupietzc4540a22025-10-14 17:39:53 +0200836 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200837 if (!is.null(replacement) && is.finite(replacement)) {
838 replacement <- min(replacement, fallback_min)
839 } else {
840 replacement <- fallback_min
841 }
842 if (!is.finite(replacement)) {
843 replacement <- 0
844 }
845 if (any(is.na(x))) {
846 x[is.na(x)] <- replacement
847 }
848 if (any(is.na(y))) {
849 y[is.na(y)] <- replacement
850 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200851 list(x = x, y = y)
852 }
853
Marc Kupietz130a2a22025-10-18 16:09:23 +0200854 fill_percentiles <- function(x, y, left_pct_col, right_pct_col) {
855 replacement_left <- percentile_replacements[[left_pct_col]]
856 if (is.null(replacement_left) || !is.finite(replacement_left)) {
857 replacement_left <- 0
858 }
859 replacement_right <- percentile_replacements[[right_pct_col]]
860 if (is.null(replacement_right) || !is.finite(replacement_right)) {
861 replacement_right <- 0
862 }
863 if (any(is.na(x))) {
864 x[is.na(x)] <- replacement_left
865 }
866 if (any(is.na(y))) {
867 y[is.na(y)] <- replacement_right
868 }
869 list(x = x, y = y)
870 }
871
Marc Kupietz28a29842025-10-18 12:25:09 +0200872 fill_ranks <- function(x, y, left_rank_col, right_rank_col) {
873 fallback <- nrow(comparison) + 1
874 replacement_left <- rank_replacements[[left_rank_col]]
875 if (is.null(replacement_left) || !is.finite(replacement_left)) {
876 replacement_left <- fallback
Marc Kupietzc4540a22025-10-14 17:39:53 +0200877 }
Marc Kupietz28a29842025-10-18 12:25:09 +0200878 replacement_right <- rank_replacements[[right_rank_col]]
879 if (is.null(replacement_right) || !is.finite(replacement_right)) {
880 replacement_right <- fallback
881 }
882 if (any(is.na(x))) {
883 x[is.na(x)] <- replacement_left
884 }
885 if (any(is.na(y))) {
886 y[is.na(y)] <- replacement_right
887 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200888 list(x = x, y = y)
889 }
890
891 left_label <- labels[1]
892 right_label <- labels[2]
893
894 for (col in score_cols) {
895 left_col <- paste0(col, "_", left_label)
896 right_col <- paste0(col, "_", right_label)
897 if (!all(c(left_col, right_col) %in% names(comparison))) {
898 next
899 }
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200900 filled <- fill_scores(comparison[[left_col]], comparison[[right_col]], col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200901 comparison[[left_col]] <- filled$x
902 comparison[[right_col]] <- filled$y
Marc Kupietzc4540a22025-10-14 17:39:53 +0200903 comparison[[paste0("delta_", col)]] <- filled$x - filled$y
Marc Kupietz28a29842025-10-18 12:25:09 +0200904 rank_left <- paste0("rank_", left_label, "_", col)
905 rank_right <- paste0("rank_", right_label, "_", col)
906 if (all(c(rank_left, rank_right) %in% names(comparison))) {
907 filled_rank <- fill_ranks(
908 comparison[[rank_left]],
909 comparison[[rank_right]],
910 rank_left,
911 rank_right
912 )
913 comparison[[paste0("delta_rank_", col)]] <- filled_rank$x - filled_rank$y
914 }
Marc Kupietz130a2a22025-10-18 16:09:23 +0200915 pct_left <- paste0("percentile_rank_", left_label, "_", col)
916 pct_right <- paste0("percentile_rank_", right_label, "_", col)
917 if (all(c(pct_left, pct_right) %in% names(comparison))) {
918 filled_pct <- fill_percentiles(
919 comparison[[pct_left]],
920 comparison[[pct_right]],
921 pct_left,
922 pct_right
923 )
924 comparison[[paste0("delta_percentile_rank_", col)]] <- filled_pct$x - filled_pct$y
925 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200926 }
927 }
928
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200929 for (col in score_cols) {
930 value_cols <- paste0(col, "_", labels)
931 existing <- value_cols %in% names(comparison)
932 if (!any(existing)) {
933 next
934 }
935 value_cols <- value_cols[existing]
936 safe_labels <- labels[existing]
937
938 score_values <- comparison[, value_cols, drop = FALSE]
939
940 winner_label_col <- paste0("winner_", col)
941 winner_value_col <- paste0("winner_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +0200942 winner_url_col <- paste0("winner_", col, "_webUIRequestUrl")
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200943 runner_label_col <- paste0("runner_up_", col)
944 runner_value_col <- paste0("runner_up_", col, "_value")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200945 loser_label_col <- paste0("loser_", col)
946 loser_value_col <- paste0("loser_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +0200947 loser_url_col <- paste0("loser_", col, "_webUIRequestUrl")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200948 max_delta_col <- paste0("max_delta_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200949 url_cols <- paste0("webUIRequestUrl_", safe_labels)
950 has_urls <- all(url_cols %in% names(comparison))
951 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200952
953 if (nrow(score_values) == 0) {
954 comparison[[winner_label_col]] <- character(0)
955 comparison[[winner_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200956 if (has_urls) {
957 comparison[[winner_url_col]] <- character(0)
958 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200959 comparison[[runner_label_col]] <- character(0)
960 comparison[[runner_value_col]] <- numeric(0)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200961 comparison[[loser_label_col]] <- character(0)
962 comparison[[loser_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200963 if (has_urls) {
964 comparison[[loser_url_col]] <- character(0)
965 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200966 comparison[[max_delta_col]] <- numeric(0)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200967 next
968 }
969
970 score_matrix <- as.matrix(score_values)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200971 storage.mode(score_matrix) <- "numeric"
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200972
Marc Kupietzb2862d42025-10-18 10:17:49 +0200973 n_rows <- nrow(score_matrix)
974 winner_labels <- rep(NA_character_, n_rows)
975 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200976 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200977 runner_labels <- rep(NA_character_, n_rows)
978 runner_values <- rep(NA_real_, n_rows)
979 loser_labels <- rep(NA_character_, n_rows)
980 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200981 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200982 max_deltas <- rep(NA_real_, n_rows)
983
Marc Kupietzb2862d42025-10-18 10:17:49 +0200984 if (n_rows > 0) {
985 for (i in seq_len(n_rows)) {
986 numeric_row <- as.numeric(score_matrix[i, ])
987 if (all(is.na(numeric_row))) {
988 next
989 }
990
Marc Kupietz9894a372025-10-18 14:51:29 +0200991 replacement <- score_replacements[[col]]
992 fallback_min <- suppressWarnings(min(numeric_row, na.rm = TRUE))
993 if (!is.finite(fallback_min)) {
994 fallback_min <- 0
Marc Kupietzb2862d42025-10-18 10:17:49 +0200995 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200996 if (!is.null(replacement) && is.finite(replacement)) {
997 replacement <- min(replacement, fallback_min)
998 } else {
999 replacement <- fallback_min
1000 }
1001 if (!is.finite(replacement)) {
1002 replacement <- 0
1003 }
1004 if (any(is.na(numeric_row))) {
1005 numeric_row[is.na(numeric_row)] <- replacement
1006 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001007 score_matrix[i, ] <- numeric_row
1008
1009 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1010 max_idx <- which(numeric_row == max_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001011 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001012 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001013 if (has_urls) {
1014 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1015 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001016
1017 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1018 if (length(unique_vals) >= 2) {
1019 runner_val <- unique_vals[2]
1020 runner_idx <- which(numeric_row == runner_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001021 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001022 runner_values[i] <- runner_val
1023 }
1024
1025 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1026 min_idx <- which(numeric_row == min_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001027 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001028 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001029 if (has_urls) {
1030 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1031 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001032
1033 if (is.finite(max_val) && is.finite(min_val)) {
1034 max_deltas[i] <- max_val - min_val
1035 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001036 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001037 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001038
Marc Kupietzb2862d42025-10-18 10:17:49 +02001039 comparison[, value_cols] <- score_matrix
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001040 comparison[[winner_label_col]] <- winner_labels
1041 comparison[[winner_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001042 if (has_urls) {
1043 comparison[[winner_url_col]] <- winner_urls
1044 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001045 comparison[[runner_label_col]] <- runner_labels
1046 comparison[[runner_value_col]] <- runner_values
Marc Kupietzb2862d42025-10-18 10:17:49 +02001047 comparison[[loser_label_col]] <- loser_labels
1048 comparison[[loser_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001049 if (has_urls) {
1050 comparison[[loser_url_col]] <- loser_urls
1051 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001052 comparison[[max_delta_col]] <- max_deltas
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001053 }
1054
Marc Kupietz28a29842025-10-18 12:25:09 +02001055 for (col in score_cols) {
1056 rank_cols <- paste0("rank_", labels, "_", col)
1057 existing <- rank_cols %in% names(comparison)
1058 if (!any(existing)) {
1059 next
1060 }
1061 rank_cols <- rank_cols[existing]
1062 safe_labels <- labels[existing]
1063 rank_values <- comparison[, rank_cols, drop = FALSE]
1064
1065 winner_rank_label_col <- paste0("winner_rank_", col)
1066 winner_rank_value_col <- paste0("winner_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001067 winner_rank_url_col <- paste0("winner_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001068 runner_rank_label_col <- paste0("runner_up_rank_", col)
1069 runner_rank_value_col <- paste0("runner_up_rank_", col, "_value")
1070 loser_rank_label_col <- paste0("loser_rank_", col)
1071 loser_rank_value_col <- paste0("loser_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001072 loser_rank_url_col <- paste0("loser_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001073 max_delta_rank_col <- paste0("max_delta_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001074 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1075 has_urls <- all(url_cols %in% names(comparison))
1076 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz28a29842025-10-18 12:25:09 +02001077
1078 if (nrow(rank_values) == 0) {
1079 comparison[[winner_rank_label_col]] <- character(0)
1080 comparison[[winner_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001081 if (has_urls) {
1082 comparison[[winner_rank_url_col]] <- character(0)
1083 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001084 comparison[[runner_rank_label_col]] <- character(0)
1085 comparison[[runner_rank_value_col]] <- numeric(0)
1086 comparison[[loser_rank_label_col]] <- character(0)
1087 comparison[[loser_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001088 if (has_urls) {
1089 comparison[[loser_rank_url_col]] <- character(0)
1090 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001091 comparison[[max_delta_rank_col]] <- numeric(0)
1092 next
1093 }
1094
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001095 rank_matrix <- as.matrix(rank_values)
1096 storage.mode(rank_matrix) <- "numeric"
Marc Kupietz28a29842025-10-18 12:25:09 +02001097
1098 n_rows <- nrow(rank_matrix)
1099 winner_labels <- rep(NA_character_, n_rows)
1100 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001101 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001102 runner_labels <- rep(NA_character_, n_rows)
1103 runner_values <- rep(NA_real_, n_rows)
1104 loser_labels <- rep(NA_character_, n_rows)
1105 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001106 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001107 max_deltas <- rep(NA_real_, n_rows)
1108
1109 for (i in seq_len(n_rows)) {
1110 numeric_row <- as.numeric(rank_matrix[i, ])
1111 if (all(is.na(numeric_row))) {
1112 next
1113 }
1114
1115 if (length(rank_cols) > 0) {
1116 replacement_vec <- rank_replacements[rank_cols]
1117 replacement_vec[is.na(replacement_vec)] <- nrow(comparison) + 1
1118 missing_idx <- which(is.na(numeric_row))
1119 if (length(missing_idx) > 0) {
1120 numeric_row[missing_idx] <- replacement_vec[missing_idx]
1121 }
1122 }
1123
1124 valid_idx <- seq_along(numeric_row)
1125 valid_values <- numeric_row[valid_idx]
1126 min_val <- suppressWarnings(min(valid_values, na.rm = TRUE))
1127 min_positions <- valid_idx[which(valid_values == min_val)]
1128 winner_labels[i] <- collapse_label_values(min_positions, safe_labels)
1129 winner_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001130 if (has_urls) {
1131 winner_urls[i] <- collapse_url_values(min_positions, url_values[i, ])
1132 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001133
1134 ordered_vals <- sort(unique(valid_values), decreasing = FALSE)
1135 if (length(ordered_vals) >= 2) {
1136 runner_val <- ordered_vals[2]
1137 runner_positions <- valid_idx[which(valid_values == runner_val)]
1138 runner_labels[i] <- collapse_label_values(runner_positions, safe_labels)
1139 runner_values[i] <- runner_val
1140 }
1141
1142 max_val <- suppressWarnings(max(valid_values, na.rm = TRUE))
1143 max_positions <- valid_idx[which(valid_values == max_val)]
1144 loser_labels[i] <- collapse_label_values(max_positions, safe_labels)
1145 loser_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001146 if (has_urls) {
1147 loser_urls[i] <- collapse_url_values(max_positions, url_values[i, ])
1148 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001149
1150 if (is.finite(max_val) && is.finite(min_val)) {
1151 max_deltas[i] <- max_val - min_val
1152 }
1153 }
1154
1155 comparison[[winner_rank_label_col]] <- winner_labels
1156 comparison[[winner_rank_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001157 if (has_urls) {
1158 comparison[[winner_rank_url_col]] <- winner_urls
1159 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001160 comparison[[runner_rank_label_col]] <- runner_labels
1161 comparison[[runner_rank_value_col]] <- runner_values
1162 comparison[[loser_rank_label_col]] <- loser_labels
1163 comparison[[loser_rank_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001164 if (has_urls) {
1165 comparison[[loser_rank_url_col]] <- loser_urls
1166 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001167 comparison[[max_delta_rank_col]] <- max_deltas
1168 }
1169
Marc Kupietz130a2a22025-10-18 16:09:23 +02001170 for (col in score_cols) {
1171 pct_cols <- paste0("percentile_rank_", labels, "_", col)
1172 existing <- pct_cols %in% names(comparison)
1173 if (!any(existing)) {
1174 next
1175 }
1176 pct_cols <- pct_cols[existing]
1177 safe_labels <- labels[existing]
1178 pct_values <- comparison[, pct_cols, drop = FALSE]
1179
1180 winner_pct_label_col <- paste0("winner_percentile_rank_", col)
1181 winner_pct_value_col <- paste0("winner_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001182 winner_pct_url_col <- paste0("winner_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001183 runner_pct_label_col <- paste0("runner_up_percentile_rank_", col)
1184 runner_pct_value_col <- paste0("runner_up_percentile_rank_", col, "_value")
1185 loser_pct_label_col <- paste0("loser_percentile_rank_", col)
1186 loser_pct_value_col <- paste0("loser_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001187 loser_pct_url_col <- paste0("loser_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001188 max_delta_pct_col <- paste0("max_delta_percentile_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001189 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1190 has_urls <- all(url_cols %in% names(comparison))
1191 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz130a2a22025-10-18 16:09:23 +02001192
1193 if (nrow(pct_values) == 0) {
1194 comparison[[winner_pct_label_col]] <- character(0)
1195 comparison[[winner_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001196 if (has_urls) {
1197 comparison[[winner_pct_url_col]] <- character(0)
1198 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001199 comparison[[runner_pct_label_col]] <- character(0)
1200 comparison[[runner_pct_value_col]] <- numeric(0)
1201 comparison[[loser_pct_label_col]] <- character(0)
1202 comparison[[loser_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001203 if (has_urls) {
1204 comparison[[loser_pct_url_col]] <- character(0)
1205 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001206 comparison[[max_delta_pct_col]] <- numeric(0)
1207 next
1208 }
1209
1210 pct_matrix <- as.matrix(pct_values)
1211 storage.mode(pct_matrix) <- "numeric"
1212
1213 n_rows <- nrow(pct_matrix)
1214 winner_labels <- rep(NA_character_, n_rows)
1215 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001216 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001217 runner_labels <- rep(NA_character_, n_rows)
1218 runner_values <- rep(NA_real_, n_rows)
1219 loser_labels <- rep(NA_character_, n_rows)
1220 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001221 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001222 max_deltas <- rep(NA_real_, n_rows)
1223
1224 if (n_rows > 0) {
1225 for (i in seq_len(n_rows)) {
1226 numeric_row <- as.numeric(pct_matrix[i, ])
1227 if (all(is.na(numeric_row))) {
1228 next
1229 }
1230
1231 if (any(is.na(numeric_row))) {
1232 numeric_row[is.na(numeric_row)] <- 0
1233 }
1234 pct_matrix[i, ] <- numeric_row
1235
1236 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1237 max_idx <- which(numeric_row == max_val)
1238 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
1239 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001240 if (has_urls) {
1241 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1242 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001243
1244 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1245 if (length(unique_vals) >= 2) {
1246 runner_val <- unique_vals[2]
1247 runner_idx <- which(numeric_row == runner_val)
1248 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
1249 runner_values[i] <- runner_val
1250 }
1251
1252 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1253 min_idx <- which(numeric_row == min_val)
1254 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
1255 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001256 if (has_urls) {
1257 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1258 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001259
1260 if (is.finite(max_val) && is.finite(min_val)) {
1261 max_deltas[i] <- max_val - min_val
1262 }
1263 }
1264 }
1265
1266 comparison[, pct_cols] <- pct_matrix
1267 comparison[[winner_pct_label_col]] <- winner_labels
1268 comparison[[winner_pct_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001269 if (has_urls) {
1270 comparison[[winner_pct_url_col]] <- winner_urls
1271 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001272 comparison[[runner_pct_label_col]] <- runner_labels
1273 comparison[[runner_pct_value_col]] <- runner_values
1274 comparison[[loser_pct_label_col]] <- loser_labels
1275 comparison[[loser_pct_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001276 if (has_urls) {
1277 comparison[[loser_pct_url_col]] <- loser_urls
1278 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001279 comparison[[max_delta_pct_col]] <- max_deltas
1280 }
1281
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +02001282 for (flag_col in names(imputed_flags)) {
1283 comparison[[flag_col]] <- imputed_flags[[flag_col]]
1284 }
1285 if (length(imputed_flags) > 0) {
1286 comparison$n_imputed <- as.integer(Reduce(`+`, lapply(imputed_flags, as.integer)))
1287 } else {
1288 comparison$n_imputed <- rep(0L, nrow(comparison))
1289 }
1290 comparison$imputed <- comparison$n_imputed > 0L
1291
Marc Kupietz424cb782026-08-31 10:19:29 +02001292 n_imputed_rows <- sum(comparison$imputed)
1293 if (n_imputed_rows > 0) {
1294 log_info(verbose, sprintf(
1295 paste0(
1296 "Imputed scores for %d of %d node/collocate combinations (%d of %d label cells) ",
1297 "that are not attested in every virtual corpus. Their delta and winner/loser ",
1298 "columns reflect presence vs. absence rather than a measured contrast; see the ",
1299 "`imputed` column and `queryMissingScores`.\n"
1300 ),
1301 n_imputed_rows,
1302 nrow(comparison),
1303 sum(comparison$n_imputed),
1304 nrow(comparison) * length(labels)
1305 ))
1306 }
1307
Marc Kupietz09b1c082026-05-01 14:45:47 +02001308 collapse_consensus_url_columns <- function(url_cols) {
1309 if (length(url_cols) == 0) {
1310 return(rep(NA_character_, nrow(comparison)))
1311 }
1312 vapply(seq_len(nrow(comparison)), function(i) {
1313 urls <- unlist(comparison[i, url_cols, drop = FALSE], use.names = FALSE)
1314 urls <- as.character(urls)
1315 urls <- urls[!is.na(urls) & urls != ""]
1316 urls <- unique(urls)
1317 if (length(urls) == 1) {
1318 urls
1319 } else {
1320 NA_character_
1321 }
1322 }, character(1))
1323 }
1324
1325 winner_score_url_cols <- intersect(paste0("winner_", score_cols, "_webUIRequestUrl"), names(comparison))
1326 loser_score_url_cols <- intersect(paste0("loser_", score_cols, "_webUIRequestUrl"), names(comparison))
1327 if (length(winner_score_url_cols) > 0) {
1328 comparison$winner_webUIRequestUrl <- collapse_consensus_url_columns(winner_score_url_cols)
1329 }
1330 if (length(loser_score_url_cols) > 0) {
1331 comparison$loser_webUIRequestUrl <- collapse_consensus_url_columns(loser_score_url_cols)
1332 }
1333
1334 url_helper_cols <- intersect(paste0("webUIRequestUrl_", labels), names(comparison))
1335 if (length(url_helper_cols) > 0) {
1336 comparison <- dplyr::select(comparison, -dplyr::all_of(url_helper_cols))
1337 }
1338
Marc Kupietzc4540a22025-10-14 17:39:53 +02001339 dplyr::left_join(result, comparison, by = c("node", "collocate"))
1340}
1341
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001342#' @importFrom magrittr debug_pipe
Marc Kupietz2b17b212023-08-27 17:47:26 +02001343#' @importFrom stringr str_detect
1344#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1345#'
1346matches2FreqTable <- function(matches,
1347 index = 0,
1348 minOccur = 5,
1349 leftContextSize = 5,
1350 rightContextSize = 5,
1351 ignoreCollocateCase = FALSE,
1352 stopwords = c(),
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001353 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz2b17b212023-08-27 17:47:26 +02001354 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1355 verbose = TRUE) {
1356 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1357 frequency <- NULL
1358
1359 if (nrow(matches) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001360 dplyr::tibble(word = c(), frequency = c())
Marc Kupietz2b17b212023-08-27 17:47:26 +02001361 } else if (index == 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001362 if (!"tokens" %in% colnames(matches) || !is.list(matches$tokens)) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001363 log_info(verbose, "Outdated KorAP server: Falling back to client side tokenization.\n")
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001364 return(snippet2FreqTable(matches$snippet, minOccur, leftContextSize, rightContextSize,
1365 ignoreCollocateCase = ignoreCollocateCase,
1366 stopwords = stopwords, oldTable = oldTable, verbose = verbose
1367 ))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001368 }
1369 log_info(verbose, paste("Joining", nrow(matches), "kwics\n"))
Marc Kupietza25fbd92025-10-14 17:38:09 +02001370 for (i in seq_len(nrow(matches))) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001371 oldTable <- matches2FreqTable(
1372 matches,
1373 i,
1374 leftContextSize = leftContextSize,
1375 rightContextSize = rightContextSize,
1376 collocateFilterRegex = collocateFilterRegex,
1377 oldTable = oldTable,
1378 stopwords = stopwords
1379 )
1380 }
1381 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001382 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001383 group_by(word) |>
1384 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001385 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001386 arrange(desc(frequency))
1387 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001388 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietz2b17b212023-08-27 17:47:26 +02001389
1390 left <- tail(unlist(matches$tokens$left[index]), leftContextSize)
1391
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001392 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001393
1394 right <- head(unlist(matches$tokens$right[index]), rightContextSize)
1395
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001396 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001397
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001398 if (length(left) + length(right) == 0) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001399 oldTable
1400 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001401 table(c(left, right)) |>
1402 dplyr::as_tibble(.name_repair = "minimal") |>
1403 dplyr::rename(word = 1, frequency = 2) |>
1404 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001405 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001406 dplyr::bind_rows(oldTable)
1407 }
1408 }
1409}
1410
1411#' @importFrom magrittr debug_pipe
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001412#' @importFrom stringr str_match str_split str_detect
1413#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1414#'
1415snippet2FreqTable <- function(snippet,
1416 minOccur = 5,
1417 leftContextSize = 5,
1418 rightContextSize = 5,
1419 ignoreCollocateCase = FALSE,
1420 stopwords = c(),
1421 tokenizeRegex = "([! )(\uc2\uab,.:?\u201e\u201c\'\"]+|&quot;)",
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001422 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001423 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1424 verbose = TRUE) {
1425 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1426 frequency <- NULL
1427
1428 if (length(snippet) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001429 dplyr::tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001430 } else if (length(snippet) > 1) {
Marc Kupietza47d1502023-04-18 15:26:47 +02001431 log_info(verbose, paste("Joining", length(snippet), "kwics\n"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001432 for (s in snippet) {
1433 oldTable <- snippet2FreqTable(
1434 s,
1435 leftContextSize = leftContextSize,
1436 rightContextSize = rightContextSize,
Marc Kupietz47d0d2b2021-12-19 16:38:52 +01001437 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001438 oldTable = oldTable,
1439 stopwords = stopwords
1440 )
1441 }
Marc Kupietza47d1502023-04-18 15:26:47 +02001442 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001443 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001444 group_by(word) |>
1445 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001446 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001447 arrange(desc(frequency))
1448 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001449 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001450 match <-
1451 str_match(
1452 snippet,
1453 '<span class="context-left">(<span class="more"></span>)?(.*[^ ]) *</span><span class="match"><mark>.*</mark></span><span class="context-right"> *([^<]*)'
1454 )
1455
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001456 left <- if (leftContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001457 tail(unlist(str_split(match[1, 3], tokenizeRegex)), leftContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001458 } else {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001459 ""
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001460 }
1461 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001462
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001463 right <- if (rightContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001464 head(unlist(str_split(match[1, 4], tokenizeRegex)), rightContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001465 } else {
1466 ""
1467 }
1468 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001469
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001470 if (is.na(left[1]) || is.na(right[1]) || length(left) + length(right) == 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001471 oldTable
1472 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001473 table(c(left, right)) |>
1474 dplyr::as_tibble(.name_repair = "minimal") |>
1475 dplyr::rename(word = 1, frequency = 2) |>
1476 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001477 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001478 dplyr::bind_rows(oldTable)
1479 }
1480 }
1481}
1482
1483#' Preliminary synsemantic stopwords function
1484#'
1485#' @description
Marc Kupietz67edcb52021-09-20 21:54:24 +02001486#' `r lifecycle::badge("experimental")`
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001487#'
1488#' Preliminary synsemantic stopwords function to be used in collocation analysis.
1489#'
1490#' @details
1491#' Currently only suitable for German. See stopwords package for other languages.
1492#'
1493#' @param ... future arguments for language detection
1494#'
1495#' @family collocation analysis functions
1496#' @return Vector of synsemantic stopwords.
1497#' @export
1498synsemanticStopwords <- function(...) {
Marc Kupietzc79155b2025-10-19 13:42:55 +02001499 base <- c(
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001500 "der",
1501 "die",
1502 "und",
1503 "in",
1504 "den",
1505 "von",
1506 "mit",
1507 "das",
1508 "zu",
1509 "im",
1510 "ist",
1511 "auf",
1512 "sich",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001513 "des",
1514 "dem",
1515 "nicht",
1516 "ein",
1517 "eine",
1518 "es",
1519 "auch",
1520 "an",
1521 "als",
1522 "am",
1523 "aus",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001524 "bei",
1525 "er",
1526 "dass",
1527 "sie",
1528 "nach",
1529 "um",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001530 "zum",
1531 "noch",
1532 "war",
1533 "einen",
1534 "einer",
1535 "wie",
1536 "einem",
1537 "vor",
1538 "bis",
1539 "\u00fcber",
1540 "so",
1541 "aber",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001542 "diese",
Marc Kupietzc79155b2025-10-19 13:42:55 +02001543 "oder"
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001544 )
Marc Kupietzc79155b2025-10-19 13:42:55 +02001545
1546 lower <- unique(tolower(base))
1547 capitalized <- paste0(toupper(substr(lower, 1, 1)), substring(lower, 2))
1548
1549 unique(c(lower, capitalized))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001550}
1551
Marc Kupietz5a336b62021-11-27 17:51:35 +01001552
Marc Kupietz76b05592021-12-19 16:26:15 +01001553# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +01001554findExample <-
1555 function(kco,
1556 query,
1557 vc = "",
1558 matchOnly = TRUE) {
1559 out <- character(length = length(query))
1560
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001561 if (length(vc) < length(query)) {
Marc Kupietz5a336b62021-11-27 17:51:35 +01001562 vc <- rep(vc, length(query))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001563 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001564
1565 for (i in seq_along(query)) {
1566 q <- corpusQuery(kco, paste0("(", query[i], ")"), vc = vc[i], metadataOnly = FALSE)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001567 if (q@totalResults > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001568 q <- fetchNext(q, maxFetch = 50, randomizePageOrder = F)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001569 example <- as.character((q@collectedMatches)$snippet[1])
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001570 out[i] <- if (matchOnly) {
1571 gsub(".*<mark>(.+)</mark>.*", "\\1", example)
Marc Kupietz5a336b62021-11-27 17:51:35 +01001572 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001573 stringr::str_replace(example, "<[^>]*>", "")
Marc Kupietz5a336b62021-11-27 17:51:35 +01001574 }
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001575 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001576 out[i] <- ""
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001577 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001578 }
1579 out
1580 }
1581
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001582collocatesQuery <-
1583 function(kco,
1584 query,
1585 vc = "",
1586 minOccur = 5,
1587 leftContextSize = 5,
1588 rightContextSize = 5,
1589 searchHitsSampleLimit = 20000,
1590 ignoreCollocateCase = FALSE,
1591 stopwords = c(),
Marc Kupietzb2862d42025-10-18 10:17:49 +02001592 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001593 ...) {
1594 frequency <- NULL
1595 q <- corpusQuery(kco, query, vc, metadataOnly = F, ...)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001596 if (q@totalResults == 0) {
1597 tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001598 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001599 q <- fetchNext(q, maxFetch = searchHitsSampleLimit, randomizePageOrder = TRUE)
1600 matches2FreqTable(q@collectedMatches,
1601 0,
1602 minOccur = minOccur,
1603 leftContextSize = leftContextSize,
1604 rightContextSize = rightContextSize,
1605 ignoreCollocateCase = ignoreCollocateCase,
1606 stopwords = stopwords,
Marc Kupietzb2862d42025-10-18 10:17:49 +02001607 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001608 ...,
1609 verbose = kco@verbose
1610 ) |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001611 mutate(frequency = frequency * q@totalResults / min(q@totalResults, searchHitsSampleLimit)) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001612 filter(frequency >= minOccur)
1613 }
1614 }