blob: d185a0c916369e063086e6c180fa6218f5951aaf [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 Kupietz7b7a73b2026-08-31 10:31:27 +0200664 # The pivots below keep only the first row per node/collocate/label. Duplicates do occur
665 # legitimately (e.g. the same collocate found at several context positions), but silently
666 # discarding all but one of them would misrepresent the comparison, so say so.
667 comparison_keys <- paste(result$node, result$collocate, result$label, sep = "\r")
668 duplicate_keys <- unique(comparison_keys[duplicated(comparison_keys)])
669 if (length(duplicate_keys) > 0) {
670 warning(
671 sprintf(
672 paste0(
673 "%d node/collocate/label combination(s) occur more than once; only the first row ",
674 "of each is used for the multi-VC comparison columns. Consider ",
675 "mergeDuplicateCollocates() to combine context positions before comparing."
676 ),
677 length(duplicate_keys)
678 ),
679 call. = FALSE
680 )
681 }
682
Marc Kupietzc4540a22025-10-14 17:39:53 +0200683 comparison <- result |>
Marc Kupietz28a29842025-10-18 12:25:09 +0200684 dplyr::select(node, collocate, label, dplyr::all_of(score_cols)) |>
685 tidyr::pivot_wider(
Marc Kupietzc4540a22025-10-14 17:39:53 +0200686 names_from = label,
Marc Kupietz28a29842025-10-18 12:25:09 +0200687 values_from = dplyr::all_of(score_cols),
Marc Kupietzc4540a22025-10-14 17:39:53 +0200688 names_glue = "{.value}_{make.names(label)}",
689 values_fn = dplyr::first
690 )
691
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200692 raw_labels <- unique(result$label)
693 labels <- make.names(raw_labels)
694 label_map <- stats::setNames(raw_labels, labels)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200695 vc_map <- result |>
696 dplyr::select(label, vc) |>
697 dplyr::filter(!is.na(label), label != "") |>
698 dplyr::distinct(label, .keep_all = TRUE)
699 vc_map <- stats::setNames(vc_map$vc, make.names(vc_map$label))
700
701 replace_web_ui_cq <- function(url, vc_value) {
702 if (length(url) == 0 || is.na(url) || url == "") {
703 return(NA_character_)
704 }
705 if (length(vc_value) == 0 || is.na(vc_value)) {
706 vc_value <- ""
707 }
708 encoded_vc <- urltools::url_encode(enc2utf8(as.character(vc_value)))
709 if (grepl("([?&]cq=)[^&]*", url, perl = TRUE)) {
710 return(sub("([?&]cq=)[^&]*", paste0("\\1", encoded_vc), url, perl = TRUE))
711 }
712 if (encoded_vc == "") {
713 return(url)
714 }
715 paste0(url, ifelse(grepl("\\?", url), "&", "?"), "cq=", encoded_vc)
716 }
717
718 if ("webUIRequestUrl" %in% names(result)) {
719 url_data <- result |>
720 dplyr::select(node, collocate, label, webUIRequestUrl) |>
721 tidyr::pivot_wider(
722 names_from = label,
723 values_from = webUIRequestUrl,
724 names_glue = "webUIRequestUrl_{make.names(label)}",
725 values_fn = dplyr::first
726 )
727
728 comparison <- dplyr::left_join(comparison, url_data, by = c("node", "collocate"))
729
730 url_cols <- paste0("webUIRequestUrl_", labels)
731 present_url_cols <- intersect(url_cols, names(comparison))
732 fallback_urls <- vapply(seq_len(nrow(comparison)), function(i) {
733 urls <- unlist(comparison[i, present_url_cols, drop = FALSE], use.names = FALSE)
734 urls <- as.character(urls)
735 urls <- urls[!is.na(urls) & urls != ""]
736 if (length(urls) == 0) {
737 NA_character_
738 } else {
739 urls[1]
740 }
741 }, character(1))
742
743 for (safe_label in labels) {
744 url_col <- paste0("webUIRequestUrl_", safe_label)
745 if (!url_col %in% names(comparison)) {
746 comparison[[url_col]] <- NA_character_
747 }
748 missing_urls <- is.na(comparison[[url_col]]) | comparison[[url_col]] == ""
749 if (any(missing_urls)) {
750 comparison[[url_col]][missing_urls] <- vapply(
751 fallback_urls[missing_urls],
752 replace_web_ui_cq,
753 character(1),
754 vc_value = vc_map[[safe_label]]
755 )
756 }
757 }
758 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200759
Marc Kupietz28a29842025-10-18 12:25:09 +0200760 rank_data <- result |>
761 dplyr::distinct(node, collocate)
762
763 for (i in seq_along(raw_labels)) {
764 raw_lab <- raw_labels[i]
765 safe_lab <- labels[i]
766 label_df <- result[result$label == raw_lab, c("node", "collocate", score_cols), drop = FALSE]
767 if (nrow(label_df) == 0) {
768 next
769 }
770 label_df <- dplyr::distinct(label_df)
771 rank_tbl <- label_df[, c("node", "collocate"), drop = FALSE]
772 for (col in score_cols) {
773 rank_col_name <- paste0("rank_", safe_lab, "_", col)
Marc Kupietz130a2a22025-10-18 16:09:23 +0200774 percentile_col_name <- paste0("percentile_rank_", safe_lab, "_", col)
Marc Kupietz28a29842025-10-18 12:25:09 +0200775 values <- label_df[[col]]
776 ranks <- rep(NA_real_, length(values))
Marc Kupietz130a2a22025-10-18 16:09:23 +0200777 percentiles <- rep(NA_real_, length(values))
Marc Kupietz28a29842025-10-18 12:25:09 +0200778 valid_idx <- which(!is.na(values))
779 if (length(valid_idx) > 0) {
780 ranks[valid_idx] <- rank(-values[valid_idx], ties.method = "first")
Marc Kupietz130a2a22025-10-18 16:09:23 +0200781 total <- length(valid_idx)
782 percentiles[valid_idx] <- 1 - (ranks[valid_idx] - 1) / total
Marc Kupietz28a29842025-10-18 12:25:09 +0200783 }
784 rank_tbl[[rank_col_name]] <- ranks
Marc Kupietz130a2a22025-10-18 16:09:23 +0200785 rank_tbl[[percentile_col_name]] <- percentiles
Marc Kupietz28a29842025-10-18 12:25:09 +0200786 }
787 rank_data <- dplyr::left_join(rank_data, rank_tbl, by = c("node", "collocate"))
788 }
789
790 comparison <- dplyr::left_join(comparison, rank_data, by = c("node", "collocate"))
791
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +0200792 # Record which label/measure cells are absent *before* any imputation happens below.
793 # Deltas computed from imputed cells reflect presence/absence of the collocate in a
794 # virtual corpus, not a measured contrast, so users need to be able to tell them apart.
795 imputed_flags <- lapply(labels, function(safe_label) {
796 label_score_cols <- intersect(paste0(score_cols, "_", safe_label), names(comparison))
797 if (length(label_score_cols) == 0) {
798 return(rep(FALSE, nrow(comparison)))
799 }
800 Reduce(`|`, lapply(label_score_cols, function(col) is.na(comparison[[col]])))
801 })
802 names(imputed_flags) <- paste0("imputed_", labels)
803
Marc Kupietz28a29842025-10-18 12:25:09 +0200804 rank_replacements <- numeric(0)
805 rank_column_names <- grep("^rank_", names(comparison), value = TRUE)
806 if (length(rank_column_names) > 0) {
807 rank_replacements <- stats::setNames(
808 vapply(rank_column_names, function(col) {
809 col_values <- comparison[[col]]
810 valid_values <- col_values[!is.na(col_values)]
811 if (length(valid_values) == 0) {
812 nrow(comparison) + 1
813 } else {
814 suppressWarnings(max(valid_values, na.rm = TRUE)) + 1
815 }
816 }, numeric(1)),
817 rank_column_names
818 )
819 }
820
Marc Kupietz130a2a22025-10-18 16:09:23 +0200821 percentile_replacements <- numeric(0)
822 percentile_column_names <- grep("^percentile_rank_", names(comparison), value = TRUE)
823 if (length(percentile_column_names) > 0) {
824 percentile_replacements <- stats::setNames(
825 rep(0, length(percentile_column_names)),
826 percentile_column_names
827 )
828 }
829
Marc Kupietz28a29842025-10-18 12:25:09 +0200830 collapse_label_values <- function(indices, safe_labels_vec) {
831 if (length(indices) == 0) {
832 return(NA_character_)
833 }
834 labs <- label_map[safe_labels_vec[indices]]
835 fallback <- safe_labels_vec[indices]
836 labs[is.na(labs) | labs == ""] <- fallback[is.na(labs) | labs == ""]
837 labs <- labs[!is.na(labs) & labs != ""]
838 if (length(labs) == 0) {
839 return(NA_character_)
840 }
841 paste(unique(labs), collapse = ", ")
842 }
843
Marc Kupietz09b1c082026-05-01 14:45:47 +0200844 collapse_url_values <- function(indices, url_values) {
845 if (length(indices) == 0 || is.null(url_values)) {
846 return(NA_character_)
847 }
848 urls <- as.character(url_values[indices])
849 urls <- urls[!is.na(urls) & urls != ""]
850 if (length(urls) == 0) {
851 return(NA_character_)
852 }
853 paste(unique(urls), collapse = ", ")
854 }
855
Marc Kupietzc4540a22025-10-14 17:39:53 +0200856 if (length(labels) == 2) {
Marc Kupietz9894a372025-10-18 14:51:29 +0200857 fill_scores <- function(x, y, measure_col) {
858 replacement <- score_replacements[[measure_col]]
859 fallback_min <- suppressWarnings(min(c(x, y), na.rm = TRUE))
860 if (!is.finite(fallback_min)) {
861 fallback_min <- 0
Marc Kupietzc4540a22025-10-14 17:39:53 +0200862 }
Marc Kupietz9894a372025-10-18 14:51:29 +0200863 if (!is.null(replacement) && is.finite(replacement)) {
864 replacement <- min(replacement, fallback_min)
865 } else {
866 replacement <- fallback_min
867 }
868 if (!is.finite(replacement)) {
869 replacement <- 0
870 }
871 if (any(is.na(x))) {
872 x[is.na(x)] <- replacement
873 }
874 if (any(is.na(y))) {
875 y[is.na(y)] <- replacement
876 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200877 list(x = x, y = y)
878 }
879
Marc Kupietz130a2a22025-10-18 16:09:23 +0200880 fill_percentiles <- function(x, y, left_pct_col, right_pct_col) {
881 replacement_left <- percentile_replacements[[left_pct_col]]
882 if (is.null(replacement_left) || !is.finite(replacement_left)) {
883 replacement_left <- 0
884 }
885 replacement_right <- percentile_replacements[[right_pct_col]]
886 if (is.null(replacement_right) || !is.finite(replacement_right)) {
887 replacement_right <- 0
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 }
895 list(x = x, y = y)
896 }
897
Marc Kupietz28a29842025-10-18 12:25:09 +0200898 fill_ranks <- function(x, y, left_rank_col, right_rank_col) {
899 fallback <- nrow(comparison) + 1
900 replacement_left <- rank_replacements[[left_rank_col]]
901 if (is.null(replacement_left) || !is.finite(replacement_left)) {
902 replacement_left <- fallback
Marc Kupietzc4540a22025-10-14 17:39:53 +0200903 }
Marc Kupietz28a29842025-10-18 12:25:09 +0200904 replacement_right <- rank_replacements[[right_rank_col]]
905 if (is.null(replacement_right) || !is.finite(replacement_right)) {
906 replacement_right <- fallback
907 }
908 if (any(is.na(x))) {
909 x[is.na(x)] <- replacement_left
910 }
911 if (any(is.na(y))) {
912 y[is.na(y)] <- replacement_right
913 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200914 list(x = x, y = y)
915 }
916
917 left_label <- labels[1]
918 right_label <- labels[2]
919
920 for (col in score_cols) {
921 left_col <- paste0(col, "_", left_label)
922 right_col <- paste0(col, "_", right_label)
923 if (!all(c(left_col, right_col) %in% names(comparison))) {
924 next
925 }
Marc Kupietzdb2fabd2026-04-27 15:01:37 +0200926 filled <- fill_scores(comparison[[left_col]], comparison[[right_col]], col)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200927 comparison[[left_col]] <- filled$x
928 comparison[[right_col]] <- filled$y
Marc Kupietzc4540a22025-10-14 17:39:53 +0200929 comparison[[paste0("delta_", col)]] <- filled$x - filled$y
Marc Kupietz28a29842025-10-18 12:25:09 +0200930 rank_left <- paste0("rank_", left_label, "_", col)
931 rank_right <- paste0("rank_", right_label, "_", col)
932 if (all(c(rank_left, rank_right) %in% names(comparison))) {
933 filled_rank <- fill_ranks(
934 comparison[[rank_left]],
935 comparison[[rank_right]],
936 rank_left,
937 rank_right
938 )
939 comparison[[paste0("delta_rank_", col)]] <- filled_rank$x - filled_rank$y
940 }
Marc Kupietz130a2a22025-10-18 16:09:23 +0200941 pct_left <- paste0("percentile_rank_", left_label, "_", col)
942 pct_right <- paste0("percentile_rank_", right_label, "_", col)
943 if (all(c(pct_left, pct_right) %in% names(comparison))) {
944 filled_pct <- fill_percentiles(
945 comparison[[pct_left]],
946 comparison[[pct_right]],
947 pct_left,
948 pct_right
949 )
950 comparison[[paste0("delta_percentile_rank_", col)]] <- filled_pct$x - filled_pct$y
951 }
Marc Kupietzc4540a22025-10-14 17:39:53 +0200952 }
953 }
954
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200955 for (col in score_cols) {
956 value_cols <- paste0(col, "_", labels)
957 existing <- value_cols %in% names(comparison)
958 if (!any(existing)) {
959 next
960 }
961 value_cols <- value_cols[existing]
962 safe_labels <- labels[existing]
963
964 score_values <- comparison[, value_cols, drop = FALSE]
965
966 winner_label_col <- paste0("winner_", col)
967 winner_value_col <- paste0("winner_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +0200968 winner_url_col <- paste0("winner_", col, "_webUIRequestUrl")
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200969 runner_label_col <- paste0("runner_up_", col)
970 runner_value_col <- paste0("runner_up_", col, "_value")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200971 loser_label_col <- paste0("loser_", col)
972 loser_value_col <- paste0("loser_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +0200973 loser_url_col <- paste0("loser_", col, "_webUIRequestUrl")
Marc Kupietzb2862d42025-10-18 10:17:49 +0200974 max_delta_col <- paste0("max_delta_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200975 url_cols <- paste0("webUIRequestUrl_", safe_labels)
976 has_urls <- all(url_cols %in% names(comparison))
977 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200978
979 if (nrow(score_values) == 0) {
980 comparison[[winner_label_col]] <- character(0)
981 comparison[[winner_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200982 if (has_urls) {
983 comparison[[winner_url_col]] <- character(0)
984 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200985 comparison[[runner_label_col]] <- character(0)
986 comparison[[runner_value_col]] <- numeric(0)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200987 comparison[[loser_label_col]] <- character(0)
988 comparison[[loser_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +0200989 if (has_urls) {
990 comparison[[loser_url_col]] <- character(0)
991 }
Marc Kupietzb2862d42025-10-18 10:17:49 +0200992 comparison[[max_delta_col]] <- numeric(0)
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200993 next
994 }
995
996 score_matrix <- as.matrix(score_values)
Marc Kupietzb2862d42025-10-18 10:17:49 +0200997 storage.mode(score_matrix) <- "numeric"
Marc Kupietz5e35d7a2025-10-17 21:21:22 +0200998
Marc Kupietzb2862d42025-10-18 10:17:49 +0200999 n_rows <- nrow(score_matrix)
1000 winner_labels <- rep(NA_character_, n_rows)
1001 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001002 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001003 runner_labels <- rep(NA_character_, n_rows)
1004 runner_values <- rep(NA_real_, n_rows)
1005 loser_labels <- rep(NA_character_, n_rows)
1006 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001007 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001008 max_deltas <- rep(NA_real_, n_rows)
1009
Marc Kupietzb2862d42025-10-18 10:17:49 +02001010 if (n_rows > 0) {
1011 for (i in seq_len(n_rows)) {
1012 numeric_row <- as.numeric(score_matrix[i, ])
1013 if (all(is.na(numeric_row))) {
1014 next
1015 }
1016
Marc Kupietz9894a372025-10-18 14:51:29 +02001017 replacement <- score_replacements[[col]]
1018 fallback_min <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1019 if (!is.finite(fallback_min)) {
1020 fallback_min <- 0
Marc Kupietzb2862d42025-10-18 10:17:49 +02001021 }
Marc Kupietz9894a372025-10-18 14:51:29 +02001022 if (!is.null(replacement) && is.finite(replacement)) {
1023 replacement <- min(replacement, fallback_min)
1024 } else {
1025 replacement <- fallback_min
1026 }
1027 if (!is.finite(replacement)) {
1028 replacement <- 0
1029 }
1030 if (any(is.na(numeric_row))) {
1031 numeric_row[is.na(numeric_row)] <- replacement
1032 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001033 score_matrix[i, ] <- numeric_row
1034
1035 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1036 max_idx <- which(numeric_row == max_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001037 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001038 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001039 if (has_urls) {
1040 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1041 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001042
1043 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1044 if (length(unique_vals) >= 2) {
1045 runner_val <- unique_vals[2]
1046 runner_idx <- which(numeric_row == runner_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001047 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001048 runner_values[i] <- runner_val
1049 }
1050
1051 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1052 min_idx <- which(numeric_row == min_val)
Marc Kupietz28a29842025-10-18 12:25:09 +02001053 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
Marc Kupietzb2862d42025-10-18 10:17:49 +02001054 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001055 if (has_urls) {
1056 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1057 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001058
1059 if (is.finite(max_val) && is.finite(min_val)) {
1060 max_deltas[i] <- max_val - min_val
1061 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001062 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001063 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001064
Marc Kupietzb2862d42025-10-18 10:17:49 +02001065 comparison[, value_cols] <- score_matrix
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001066 comparison[[winner_label_col]] <- winner_labels
1067 comparison[[winner_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001068 if (has_urls) {
1069 comparison[[winner_url_col]] <- winner_urls
1070 }
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001071 comparison[[runner_label_col]] <- runner_labels
1072 comparison[[runner_value_col]] <- runner_values
Marc Kupietzb2862d42025-10-18 10:17:49 +02001073 comparison[[loser_label_col]] <- loser_labels
1074 comparison[[loser_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001075 if (has_urls) {
1076 comparison[[loser_url_col]] <- loser_urls
1077 }
Marc Kupietzb2862d42025-10-18 10:17:49 +02001078 comparison[[max_delta_col]] <- max_deltas
Marc Kupietz5e35d7a2025-10-17 21:21:22 +02001079 }
1080
Marc Kupietz28a29842025-10-18 12:25:09 +02001081 for (col in score_cols) {
1082 rank_cols <- paste0("rank_", labels, "_", col)
1083 existing <- rank_cols %in% names(comparison)
1084 if (!any(existing)) {
1085 next
1086 }
1087 rank_cols <- rank_cols[existing]
1088 safe_labels <- labels[existing]
1089 rank_values <- comparison[, rank_cols, drop = FALSE]
1090
1091 winner_rank_label_col <- paste0("winner_rank_", col)
1092 winner_rank_value_col <- paste0("winner_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001093 winner_rank_url_col <- paste0("winner_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001094 runner_rank_label_col <- paste0("runner_up_rank_", col)
1095 runner_rank_value_col <- paste0("runner_up_rank_", col, "_value")
1096 loser_rank_label_col <- paste0("loser_rank_", col)
1097 loser_rank_value_col <- paste0("loser_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001098 loser_rank_url_col <- paste0("loser_rank_", col, "_webUIRequestUrl")
Marc Kupietz28a29842025-10-18 12:25:09 +02001099 max_delta_rank_col <- paste0("max_delta_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001100 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1101 has_urls <- all(url_cols %in% names(comparison))
1102 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz28a29842025-10-18 12:25:09 +02001103
1104 if (nrow(rank_values) == 0) {
1105 comparison[[winner_rank_label_col]] <- character(0)
1106 comparison[[winner_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001107 if (has_urls) {
1108 comparison[[winner_rank_url_col]] <- character(0)
1109 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001110 comparison[[runner_rank_label_col]] <- character(0)
1111 comparison[[runner_rank_value_col]] <- numeric(0)
1112 comparison[[loser_rank_label_col]] <- character(0)
1113 comparison[[loser_rank_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001114 if (has_urls) {
1115 comparison[[loser_rank_url_col]] <- character(0)
1116 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001117 comparison[[max_delta_rank_col]] <- numeric(0)
1118 next
1119 }
1120
Marc Kupietzdb2fabd2026-04-27 15:01:37 +02001121 rank_matrix <- as.matrix(rank_values)
1122 storage.mode(rank_matrix) <- "numeric"
Marc Kupietz28a29842025-10-18 12:25:09 +02001123
1124 n_rows <- nrow(rank_matrix)
1125 winner_labels <- rep(NA_character_, n_rows)
1126 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001127 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001128 runner_labels <- rep(NA_character_, n_rows)
1129 runner_values <- rep(NA_real_, n_rows)
1130 loser_labels <- rep(NA_character_, n_rows)
1131 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001132 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz28a29842025-10-18 12:25:09 +02001133 max_deltas <- rep(NA_real_, n_rows)
1134
1135 for (i in seq_len(n_rows)) {
1136 numeric_row <- as.numeric(rank_matrix[i, ])
1137 if (all(is.na(numeric_row))) {
1138 next
1139 }
1140
1141 if (length(rank_cols) > 0) {
1142 replacement_vec <- rank_replacements[rank_cols]
1143 replacement_vec[is.na(replacement_vec)] <- nrow(comparison) + 1
1144 missing_idx <- which(is.na(numeric_row))
1145 if (length(missing_idx) > 0) {
1146 numeric_row[missing_idx] <- replacement_vec[missing_idx]
1147 }
1148 }
1149
1150 valid_idx <- seq_along(numeric_row)
1151 valid_values <- numeric_row[valid_idx]
1152 min_val <- suppressWarnings(min(valid_values, na.rm = TRUE))
1153 min_positions <- valid_idx[which(valid_values == min_val)]
1154 winner_labels[i] <- collapse_label_values(min_positions, safe_labels)
1155 winner_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001156 if (has_urls) {
1157 winner_urls[i] <- collapse_url_values(min_positions, url_values[i, ])
1158 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001159
1160 ordered_vals <- sort(unique(valid_values), decreasing = FALSE)
1161 if (length(ordered_vals) >= 2) {
1162 runner_val <- ordered_vals[2]
1163 runner_positions <- valid_idx[which(valid_values == runner_val)]
1164 runner_labels[i] <- collapse_label_values(runner_positions, safe_labels)
1165 runner_values[i] <- runner_val
1166 }
1167
1168 max_val <- suppressWarnings(max(valid_values, na.rm = TRUE))
1169 max_positions <- valid_idx[which(valid_values == max_val)]
1170 loser_labels[i] <- collapse_label_values(max_positions, safe_labels)
1171 loser_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001172 if (has_urls) {
1173 loser_urls[i] <- collapse_url_values(max_positions, url_values[i, ])
1174 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001175
1176 if (is.finite(max_val) && is.finite(min_val)) {
1177 max_deltas[i] <- max_val - min_val
1178 }
1179 }
1180
1181 comparison[[winner_rank_label_col]] <- winner_labels
1182 comparison[[winner_rank_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001183 if (has_urls) {
1184 comparison[[winner_rank_url_col]] <- winner_urls
1185 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001186 comparison[[runner_rank_label_col]] <- runner_labels
1187 comparison[[runner_rank_value_col]] <- runner_values
1188 comparison[[loser_rank_label_col]] <- loser_labels
1189 comparison[[loser_rank_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001190 if (has_urls) {
1191 comparison[[loser_rank_url_col]] <- loser_urls
1192 }
Marc Kupietz28a29842025-10-18 12:25:09 +02001193 comparison[[max_delta_rank_col]] <- max_deltas
1194 }
1195
Marc Kupietz130a2a22025-10-18 16:09:23 +02001196 for (col in score_cols) {
1197 pct_cols <- paste0("percentile_rank_", labels, "_", col)
1198 existing <- pct_cols %in% names(comparison)
1199 if (!any(existing)) {
1200 next
1201 }
1202 pct_cols <- pct_cols[existing]
1203 safe_labels <- labels[existing]
1204 pct_values <- comparison[, pct_cols, drop = FALSE]
1205
1206 winner_pct_label_col <- paste0("winner_percentile_rank_", col)
1207 winner_pct_value_col <- paste0("winner_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001208 winner_pct_url_col <- paste0("winner_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001209 runner_pct_label_col <- paste0("runner_up_percentile_rank_", col)
1210 runner_pct_value_col <- paste0("runner_up_percentile_rank_", col, "_value")
1211 loser_pct_label_col <- paste0("loser_percentile_rank_", col)
1212 loser_pct_value_col <- paste0("loser_percentile_rank_", col, "_value")
Marc Kupietz09b1c082026-05-01 14:45:47 +02001213 loser_pct_url_col <- paste0("loser_percentile_rank_", col, "_webUIRequestUrl")
Marc Kupietz130a2a22025-10-18 16:09:23 +02001214 max_delta_pct_col <- paste0("max_delta_percentile_rank_", col)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001215 url_cols <- paste0("webUIRequestUrl_", safe_labels)
1216 has_urls <- all(url_cols %in% names(comparison))
1217 url_values <- if (has_urls) comparison[, url_cols, drop = FALSE] else NULL
Marc Kupietz130a2a22025-10-18 16:09:23 +02001218
1219 if (nrow(pct_values) == 0) {
1220 comparison[[winner_pct_label_col]] <- character(0)
1221 comparison[[winner_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001222 if (has_urls) {
1223 comparison[[winner_pct_url_col]] <- character(0)
1224 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001225 comparison[[runner_pct_label_col]] <- character(0)
1226 comparison[[runner_pct_value_col]] <- numeric(0)
1227 comparison[[loser_pct_label_col]] <- character(0)
1228 comparison[[loser_pct_value_col]] <- numeric(0)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001229 if (has_urls) {
1230 comparison[[loser_pct_url_col]] <- character(0)
1231 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001232 comparison[[max_delta_pct_col]] <- numeric(0)
1233 next
1234 }
1235
1236 pct_matrix <- as.matrix(pct_values)
1237 storage.mode(pct_matrix) <- "numeric"
1238
1239 n_rows <- nrow(pct_matrix)
1240 winner_labels <- rep(NA_character_, n_rows)
1241 winner_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001242 winner_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001243 runner_labels <- rep(NA_character_, n_rows)
1244 runner_values <- rep(NA_real_, n_rows)
1245 loser_labels <- rep(NA_character_, n_rows)
1246 loser_values <- rep(NA_real_, n_rows)
Marc Kupietz09b1c082026-05-01 14:45:47 +02001247 loser_urls <- rep(NA_character_, n_rows)
Marc Kupietz130a2a22025-10-18 16:09:23 +02001248 max_deltas <- rep(NA_real_, n_rows)
1249
1250 if (n_rows > 0) {
1251 for (i in seq_len(n_rows)) {
1252 numeric_row <- as.numeric(pct_matrix[i, ])
1253 if (all(is.na(numeric_row))) {
1254 next
1255 }
1256
1257 if (any(is.na(numeric_row))) {
1258 numeric_row[is.na(numeric_row)] <- 0
1259 }
1260 pct_matrix[i, ] <- numeric_row
1261
1262 max_val <- suppressWarnings(max(numeric_row, na.rm = TRUE))
1263 max_idx <- which(numeric_row == max_val)
1264 winner_labels[i] <- collapse_label_values(max_idx, safe_labels)
1265 winner_values[i] <- max_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001266 if (has_urls) {
1267 winner_urls[i] <- collapse_url_values(max_idx, url_values[i, ])
1268 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001269
1270 unique_vals <- sort(unique(numeric_row), decreasing = TRUE)
1271 if (length(unique_vals) >= 2) {
1272 runner_val <- unique_vals[2]
1273 runner_idx <- which(numeric_row == runner_val)
1274 runner_labels[i] <- collapse_label_values(runner_idx, safe_labels)
1275 runner_values[i] <- runner_val
1276 }
1277
1278 min_val <- suppressWarnings(min(numeric_row, na.rm = TRUE))
1279 min_idx <- which(numeric_row == min_val)
1280 loser_labels[i] <- collapse_label_values(min_idx, safe_labels)
1281 loser_values[i] <- min_val
Marc Kupietz09b1c082026-05-01 14:45:47 +02001282 if (has_urls) {
1283 loser_urls[i] <- collapse_url_values(min_idx, url_values[i, ])
1284 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001285
1286 if (is.finite(max_val) && is.finite(min_val)) {
1287 max_deltas[i] <- max_val - min_val
1288 }
1289 }
1290 }
1291
1292 comparison[, pct_cols] <- pct_matrix
1293 comparison[[winner_pct_label_col]] <- winner_labels
1294 comparison[[winner_pct_value_col]] <- winner_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001295 if (has_urls) {
1296 comparison[[winner_pct_url_col]] <- winner_urls
1297 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001298 comparison[[runner_pct_label_col]] <- runner_labels
1299 comparison[[runner_pct_value_col]] <- runner_values
1300 comparison[[loser_pct_label_col]] <- loser_labels
1301 comparison[[loser_pct_value_col]] <- loser_values
Marc Kupietz09b1c082026-05-01 14:45:47 +02001302 if (has_urls) {
1303 comparison[[loser_pct_url_col]] <- loser_urls
1304 }
Marc Kupietz130a2a22025-10-18 16:09:23 +02001305 comparison[[max_delta_pct_col]] <- max_deltas
1306 }
1307
Marc Kupietzd7bb5cb2026-08-31 10:18:02 +02001308 for (flag_col in names(imputed_flags)) {
1309 comparison[[flag_col]] <- imputed_flags[[flag_col]]
1310 }
1311 if (length(imputed_flags) > 0) {
1312 comparison$n_imputed <- as.integer(Reduce(`+`, lapply(imputed_flags, as.integer)))
1313 } else {
1314 comparison$n_imputed <- rep(0L, nrow(comparison))
1315 }
1316 comparison$imputed <- comparison$n_imputed > 0L
1317
Marc Kupietz424cb782026-08-31 10:19:29 +02001318 n_imputed_rows <- sum(comparison$imputed)
1319 if (n_imputed_rows > 0) {
1320 log_info(verbose, sprintf(
1321 paste0(
1322 "Imputed scores for %d of %d node/collocate combinations (%d of %d label cells) ",
1323 "that are not attested in every virtual corpus. Their delta and winner/loser ",
1324 "columns reflect presence vs. absence rather than a measured contrast; see the ",
1325 "`imputed` column and `queryMissingScores`.\n"
1326 ),
1327 n_imputed_rows,
1328 nrow(comparison),
1329 sum(comparison$n_imputed),
1330 nrow(comparison) * length(labels)
1331 ))
1332 }
1333
Marc Kupietz09b1c082026-05-01 14:45:47 +02001334 collapse_consensus_url_columns <- function(url_cols) {
1335 if (length(url_cols) == 0) {
1336 return(rep(NA_character_, nrow(comparison)))
1337 }
1338 vapply(seq_len(nrow(comparison)), function(i) {
1339 urls <- unlist(comparison[i, url_cols, drop = FALSE], use.names = FALSE)
1340 urls <- as.character(urls)
1341 urls <- urls[!is.na(urls) & urls != ""]
1342 urls <- unique(urls)
1343 if (length(urls) == 1) {
1344 urls
1345 } else {
1346 NA_character_
1347 }
1348 }, character(1))
1349 }
1350
1351 winner_score_url_cols <- intersect(paste0("winner_", score_cols, "_webUIRequestUrl"), names(comparison))
1352 loser_score_url_cols <- intersect(paste0("loser_", score_cols, "_webUIRequestUrl"), names(comparison))
1353 if (length(winner_score_url_cols) > 0) {
1354 comparison$winner_webUIRequestUrl <- collapse_consensus_url_columns(winner_score_url_cols)
1355 }
1356 if (length(loser_score_url_cols) > 0) {
1357 comparison$loser_webUIRequestUrl <- collapse_consensus_url_columns(loser_score_url_cols)
1358 }
1359
1360 url_helper_cols <- intersect(paste0("webUIRequestUrl_", labels), names(comparison))
1361 if (length(url_helper_cols) > 0) {
1362 comparison <- dplyr::select(comparison, -dplyr::all_of(url_helper_cols))
1363 }
1364
Marc Kupietzc4540a22025-10-14 17:39:53 +02001365 dplyr::left_join(result, comparison, by = c("node", "collocate"))
1366}
1367
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001368#' @importFrom magrittr debug_pipe
Marc Kupietz2b17b212023-08-27 17:47:26 +02001369#' @importFrom stringr str_detect
1370#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1371#'
1372matches2FreqTable <- function(matches,
1373 index = 0,
1374 minOccur = 5,
1375 leftContextSize = 5,
1376 rightContextSize = 5,
1377 ignoreCollocateCase = FALSE,
1378 stopwords = c(),
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001379 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietz2b17b212023-08-27 17:47:26 +02001380 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1381 verbose = TRUE) {
1382 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1383 frequency <- NULL
1384
1385 if (nrow(matches) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001386 dplyr::tibble(word = c(), frequency = c())
Marc Kupietz2b17b212023-08-27 17:47:26 +02001387 } else if (index == 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001388 if (!"tokens" %in% colnames(matches) || !is.list(matches$tokens)) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001389 log_info(verbose, "Outdated KorAP server: Falling back to client side tokenization.\n")
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001390 return(snippet2FreqTable(matches$snippet, minOccur, leftContextSize, rightContextSize,
1391 ignoreCollocateCase = ignoreCollocateCase,
1392 stopwords = stopwords, oldTable = oldTable, verbose = verbose
1393 ))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001394 }
1395 log_info(verbose, paste("Joining", nrow(matches), "kwics\n"))
Marc Kupietza25fbd92025-10-14 17:38:09 +02001396 for (i in seq_len(nrow(matches))) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001397 oldTable <- matches2FreqTable(
1398 matches,
1399 i,
1400 leftContextSize = leftContextSize,
1401 rightContextSize = rightContextSize,
1402 collocateFilterRegex = collocateFilterRegex,
1403 oldTable = oldTable,
1404 stopwords = stopwords
1405 )
1406 }
1407 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001408 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001409 group_by(word) |>
1410 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001411 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001412 arrange(desc(frequency))
1413 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001414 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietz2b17b212023-08-27 17:47:26 +02001415
1416 left <- tail(unlist(matches$tokens$left[index]), leftContextSize)
1417
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001418 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001419
1420 right <- head(unlist(matches$tokens$right[index]), rightContextSize)
1421
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001422 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietz2b17b212023-08-27 17:47:26 +02001423
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001424 if (length(left) + length(right) == 0) {
Marc Kupietz2b17b212023-08-27 17:47:26 +02001425 oldTable
1426 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001427 table(c(left, right)) |>
1428 dplyr::as_tibble(.name_repair = "minimal") |>
1429 dplyr::rename(word = 1, frequency = 2) |>
1430 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001431 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietz2b17b212023-08-27 17:47:26 +02001432 dplyr::bind_rows(oldTable)
1433 }
1434 }
1435}
1436
1437#' @importFrom magrittr debug_pipe
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001438#' @importFrom stringr str_match str_split str_detect
1439#' @importFrom dplyr as_tibble tibble rename filter anti_join tibble bind_rows case_when
1440#'
1441snippet2FreqTable <- function(snippet,
1442 minOccur = 5,
1443 leftContextSize = 5,
1444 rightContextSize = 5,
1445 ignoreCollocateCase = FALSE,
1446 stopwords = c(),
1447 tokenizeRegex = "([! )(\uc2\uab,.:?\u201e\u201c\'\"]+|&quot;)",
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001448 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001449 oldTable = data.frame(word = rep(NA, 1), frequency = rep(NA, 1)),
1450 verbose = TRUE) {
1451 word <- NULL # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
1452 frequency <- NULL
1453
1454 if (length(snippet) < 1) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001455 dplyr::tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001456 } else if (length(snippet) > 1) {
Marc Kupietza47d1502023-04-18 15:26:47 +02001457 log_info(verbose, paste("Joining", length(snippet), "kwics\n"))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001458 for (s in snippet) {
1459 oldTable <- snippet2FreqTable(
1460 s,
1461 leftContextSize = leftContextSize,
1462 rightContextSize = rightContextSize,
Marc Kupietz47d0d2b2021-12-19 16:38:52 +01001463 collocateFilterRegex = collocateFilterRegex,
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001464 oldTable = oldTable,
1465 stopwords = stopwords
1466 )
1467 }
Marc Kupietza47d1502023-04-18 15:26:47 +02001468 log_info(verbose, paste("Aggregating", length(oldTable$word), "tokens\n"))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001469 oldTable |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001470 group_by(word) |>
1471 mutate(word = dplyr::case_when(ignoreCollocateCase ~ tolower(word), TRUE ~ word)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001472 summarise(frequency = sum(frequency), .groups = "drop") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001473 arrange(desc(frequency))
1474 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001475 stopwordsTable <- dplyr::tibble(word = stopwords)
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001476 match <-
1477 str_match(
1478 snippet,
1479 '<span class="context-left">(<span class="more"></span>)?(.*[^ ]) *</span><span class="match"><mark>.*</mark></span><span class="context-right"> *([^<]*)'
1480 )
1481
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001482 left <- if (leftContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001483 tail(unlist(str_split(match[1, 3], tokenizeRegex)), leftContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001484 } else {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001485 ""
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001486 }
1487 # cat(paste("left:", left, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001488
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001489 right <- if (rightContextSize > 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001490 head(unlist(str_split(match[1, 4], tokenizeRegex)), rightContextSize)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001491 } else {
1492 ""
1493 }
1494 # cat(paste("right:", right, "\n", collapse=" "))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001495
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001496 if (is.na(left[1]) || is.na(right[1]) || length(left) + length(right) == 0) {
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001497 oldTable
1498 } else {
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001499 table(c(left, right)) |>
1500 dplyr::as_tibble(.name_repair = "minimal") |>
1501 dplyr::rename(word = 1, frequency = 2) |>
1502 dplyr::filter(str_detect(word, collocateFilterRegex)) |>
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001503 dplyr::anti_join(stopwordsTable, by = "word") |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001504 dplyr::bind_rows(oldTable)
1505 }
1506 }
1507}
1508
1509#' Preliminary synsemantic stopwords function
1510#'
1511#' @description
Marc Kupietz67edcb52021-09-20 21:54:24 +02001512#' `r lifecycle::badge("experimental")`
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001513#'
1514#' Preliminary synsemantic stopwords function to be used in collocation analysis.
1515#'
1516#' @details
1517#' Currently only suitable for German. See stopwords package for other languages.
1518#'
1519#' @param ... future arguments for language detection
1520#'
1521#' @family collocation analysis functions
1522#' @return Vector of synsemantic stopwords.
1523#' @export
1524synsemanticStopwords <- function(...) {
Marc Kupietzc79155b2025-10-19 13:42:55 +02001525 base <- c(
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001526 "der",
1527 "die",
1528 "und",
1529 "in",
1530 "den",
1531 "von",
1532 "mit",
1533 "das",
1534 "zu",
1535 "im",
1536 "ist",
1537 "auf",
1538 "sich",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001539 "des",
1540 "dem",
1541 "nicht",
1542 "ein",
1543 "eine",
1544 "es",
1545 "auch",
1546 "an",
1547 "als",
1548 "am",
1549 "aus",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001550 "bei",
1551 "er",
1552 "dass",
1553 "sie",
1554 "nach",
1555 "um",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001556 "zum",
1557 "noch",
1558 "war",
1559 "einen",
1560 "einer",
1561 "wie",
1562 "einem",
1563 "vor",
1564 "bis",
1565 "\u00fcber",
1566 "so",
1567 "aber",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001568 "diese",
Marc Kupietzc79155b2025-10-19 13:42:55 +02001569 "oder"
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001570 )
Marc Kupietzc79155b2025-10-19 13:42:55 +02001571
1572 lower <- unique(tolower(base))
1573 capitalized <- paste0(toupper(substr(lower, 1, 1)), substring(lower, 2))
1574
1575 unique(c(lower, capitalized))
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001576}
1577
Marc Kupietz5a336b62021-11-27 17:51:35 +01001578
Marc Kupietz76b05592021-12-19 16:26:15 +01001579# #' @export
Marc Kupietz5a336b62021-11-27 17:51:35 +01001580findExample <-
1581 function(kco,
1582 query,
1583 vc = "",
1584 matchOnly = TRUE) {
1585 out <- character(length = length(query))
1586
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001587 if (length(vc) < length(query)) {
Marc Kupietz5a336b62021-11-27 17:51:35 +01001588 vc <- rep(vc, length(query))
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001589 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001590
1591 for (i in seq_along(query)) {
1592 q <- corpusQuery(kco, paste0("(", query[i], ")"), vc = vc[i], metadataOnly = FALSE)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001593 if (q@totalResults > 0) {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001594 q <- fetchNext(q, maxFetch = 50, randomizePageOrder = F)
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001595 example <- as.character((q@collectedMatches)$snippet[1])
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001596 out[i] <- if (matchOnly) {
1597 gsub(".*<mark>(.+)</mark>.*", "\\1", example)
Marc Kupietz5a336b62021-11-27 17:51:35 +01001598 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001599 stringr::str_replace(example, "<[^>]*>", "")
Marc Kupietz5a336b62021-11-27 17:51:35 +01001600 }
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001601 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001602 out[i] <- ""
Marc Kupietzb811ffb2021-12-07 10:34:10 +01001603 }
Marc Kupietz5a336b62021-11-27 17:51:35 +01001604 }
1605 out
1606 }
1607
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001608collocatesQuery <-
1609 function(kco,
1610 query,
1611 vc = "",
1612 minOccur = 5,
1613 leftContextSize = 5,
1614 rightContextSize = 5,
1615 searchHitsSampleLimit = 20000,
1616 ignoreCollocateCase = FALSE,
1617 stopwords = c(),
Marc Kupietzb2862d42025-10-18 10:17:49 +02001618 collocateFilterRegex = "^[:alnum:]+-?[:alnum:]*$",
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001619 ...) {
1620 frequency <- NULL
1621 q <- corpusQuery(kco, query, vc, metadataOnly = F, ...)
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001622 if (q@totalResults == 0) {
1623 tibble(word = c(), frequency = c())
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001624 } else {
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001625 q <- fetchNext(q, maxFetch = searchHitsSampleLimit, randomizePageOrder = TRUE)
1626 matches2FreqTable(q@collectedMatches,
1627 0,
1628 minOccur = minOccur,
1629 leftContextSize = leftContextSize,
1630 rightContextSize = rightContextSize,
1631 ignoreCollocateCase = ignoreCollocateCase,
1632 stopwords = stopwords,
Marc Kupietzb2862d42025-10-18 10:17:49 +02001633 collocateFilterRegex = collocateFilterRegex,
Marc Kupietz6dfeed92025-06-03 11:58:06 +02001634 ...,
1635 verbose = kco@verbose
1636 ) |>
Marc Kupietz4cd066d2025-02-28 15:48:23 +01001637 mutate(frequency = frequency * q@totalResults / min(q@totalResults, searchHitsSampleLimit)) |>
Marc Kupietzdbd431a2021-08-29 12:17:45 +02001638 filter(frequency >= minOccur)
1639 }
1640 }