Tokenize source text, match concept patterns (regex), apply exclusions, locate the matched tokens back in the source text, and save results as XLSX, JSON, and RDS files.
Usage
edstr_extract(
data = NULL,
text_input = getOption("edstr_text"),
id = NULL,
group = NULL,
sample = NULL,
seed = NULL,
ano_hash = NULL,
ano_hide = NULL,
ngram_max = 1,
concepts = NULL,
collapse = FALSE,
intersect = FALSE,
starts_with_only = TRUE,
exclus_manual = NULL,
exclus_auto_escape = NULL,
exclus_auto_token_min = 10,
regex_replace = NULL,
unmatched_data = FALSE,
concept_color = "#0099FF",
text_color = "#FF0000",
save_as_gt = FALSE,
chunk = NULL,
chunk_sort = NULL,
dirname_suffix = if (!is.null(sample)) str_glue("sample_{sample}") else NULL,
filename_suffix = dirname_suffix
)Arguments
- data
<data.frame>Input data containing at least a text column and a unique identifier column. Required unlesschunkis set, which reads the Parquet cacheedstr_clean()wrote instead.- text_input
<character(1)>Name of the text column to analyse. Defaults togetOption("edstr_text")set byedstr_config().- id
<character(1)>Name of the unique identifier column. If not supplied, detected automatically: the one column with no duplicates and noNA, aborting if none or several qualify.- group
<character(1)>Optional grouping column (e.g. patient ID when rows are documents). IfNULL, a sequentialid_groupis created.- sample
<integer(1)>Optional. Number of rows to randomly sample fromdatabefore extraction.- seed
<integer(1)>Optional. Random seed for reproducibility whensampleis used.- ano_hash
<character>Regex pattern(s) matched case-insensitively against column names; every matching column is pseudonymised by hashing. Applied todatabefore extraction, so every output frame (data$base,data$match,data$extract,data$note,sheets, and the XLSX and gt tables built from them) carries the hashed values. The hash is unsalted and truncated to 16 hexadecimal characters: it is a pseudonym, stable across runs and therefore reversible by dictionary attack on a small domain such as a patient identifier. It is not anonymisation.- ano_hide
<character>Regex pattern(s) matched case-insensitively against column names; every matching column is masked (replaced with"---"). Same application point and coverage asano_hash. A column matched by both arguments is hashed first, then masked, so"---"is what reaches the outputs.Both arguments abort rather than act silently when a pattern matches no column at all, matches the
idorgroupcolumn (which join the outputs back together), or matchestext_input. Neither argument touches the source text:data$extract,data$noteand the highlighted XLSX and gt output carrytext_inputunredacted by design. Neither touches the Parquet caches written upstream byedstr_import()andedstr_clean(), which keep the source in clear.- ngram_max
<integer(1)>Largest n-gram size to tokenise. Every smaller size is searched too, songram_max = 2covers unigrams and bigrams. Default1(unigrams only). Distinct from thengramsargument ofedstr_view(), which sets a display window around a match rather than the sizes searched.- concepts
<character|list>Named vector or nested named list of regex patterns defining the concepts to search for. Each name becomes a concept key; nested names create sub-concepts (e.g.list(cancer = list(sein = "sein|mammaire", poumon = "poumon"))). Required.- collapse
<logical(1)>IfTRUE, OR-combine concept patterns into a single regex: one per root concept as soon as at least one root holds several patterns, otherwise one regex namedconceptsfor the whole set, which drops the root names. Nesting alone does not decide it: a nested list whose roots each hold a single pattern takes the second branch. Requires at least 2 concepts.- intersect
<logical(1)>IfTRUE, keep only documents matching ALL root-level concepts. Requires at least 2 concepts.- starts_with_only
<logical(1)>IfTRUE(default), token matching uses prefix mode: the pattern must match the start of a token, and the rest of the token is accepted (\\S*$appended).- exclus_manual
<character(1)>Optional regex pattern. Matched tokens containing this pattern are excluded (manual false-positive filter).- exclus_auto_escape
<character(1)>Optional regex pattern. Tokens matching this pattern are removed fromdata_matchbefore auto-exclusion runs.- exclus_auto_token_min
<numeric(1)>Minimum n-gram size for automatic exclusion heuristics (default10). Auto-exclusions only apply to tokens withn > exclus_auto_token_min, measured in the same unit asngram_max. The default therefore disables the heuristic for every realisticngram_maxvalue: withngram_max = 3no token exceeds10. Set it to0to submit every n-gram size to the scan. When no n-gram clears the threshold the scan has nothing to look at and is skipped, so the default costs nothing.- regex_replace
<character>Optional named vector of additional regex replacements for source matching (appended to the built-in accent normalisation rules). Every element must be named, the name being the pattern its value replaces, and the value reaches the source pattern as the regex fragment it is written as. Each rule matches the token with the earlier replacements already parked, so a rule whose pattern contains a character a built-in covers (oe,ae,a,e,i,o,u, or whitespace) never fires rather than widening it:"oe"is inert,"y"is not. A rule whose pattern reaches any character (.,\\W, a negated class) also reaches that parking and is rejected. Backreferences in a replacement are not supported.- unmatched_data
<logical(1)>IfTRUE, materialise the row-levelunmatched$no_conceptset (documents whose text was searched and matched no concept, or no longer match the intersection whenintersect = TRUE), which can be large. Only the rows are gated: the countunmatched$n_no_conceptis always exact regardless. Theno_source,empty_textandoutside_psets are always populated too. DefaultFALSE.- concept_color
<character(1)>Hex colour for concept highlighting in XLSX and gt output. Default"#0099FF".- text_color
<character(1)>Hex colour for text/extract highlighting in XLSX and gt output. Default"#FF0000".- save_as_gt
<logical(1)>IfTRUE, generategt::gt()tables alongside XLSX output. Requires thegtpackage.- chunk
<integer(1)|logical(1)>Optional. Run the extraction in chunks of aboutchunkdocuments, each in its own subprocess, and combine the results into one output indistinguishable from a single-pass run.TRUEtakes the default of15000,NULL(default) andFALSEextract in one pass. A chunk is a contiguous range ofgroup, never a split one, so the budget is a soft bound: a group straddling a boundary goes whole into the later chunk, and that chunk exceeds the budget by at most the straddling group's document count. A group larger than the budget itself forms a chunk of its own, which no budget bounds, and warns. Requires thecallr,DBIandduckdbpackages.The budget is a document count because the memory a chunk needs is set by the text volume it carries, and hospital report length varies far less than the factor-2 margin
15000was chosen with: measured over three EDS corpora, about 20,600 characters per document within 25%. Re-derive it (mean(nchar(text))on the Parquet cache) only past roughly 30,000 characters per document, on another machine, or on documents that are not hospital reports.- chunk_sort
<character>Optional. Column names to orderdata$extractanddata$noteby, aftergroupand beforeid, once the metadata is joined back. Inactive by default, which leaves thegroup,idorder a single-pass run produces. Only used whenchunkis set.- dirname_suffix
<character(1)>Optional suffix appended to theextract/subdirectory, not toedstr_dirname(e.g.extract_sample_500). Defaults to"sample_{sample}"whensampleis set.- filename_suffix
<character(1)>Optional suffix appended to output file names. Defaults todirname_suffix.
Value
Invisibly, a nested list with elements:
dataList of data frames:
base(input without text),match(initial matches),extract(final extraction, carrying one0/1column per declared concept key, in that order, whether or not the concept matched anything),note(extraction with highlight markup applied).regexList:
concepts(parsed patterns),replace(replacement rules),final(combined regex),match(source-level matches).matchList:
init(all matches),final(keep/drop after exclusions).countList:
init(token-level counts),final(distinct match counts).exclusList:
match(excluded matches),count(exclusion counts).unmatchedList for documents with no token match.
n_no_concept<integer(1)>is the true-negative count, always exact regardless ofunmatched_data. Thenid/grouptibbles:no_concept(text searched, no concept matched; rows materialised only whenunmatched_data = TRUE, otherwise a 0-row tibble whilen_no_conceptstill holds the count),no_source(source empty orNA, so never searched),empty_text(source holding no text once markup is stripped),outside_p(text outside<p>blocks). Readn_no_conceptfor the denominator, nevernrow(no_concept). A document whose every match is excluded counts as a non-case inno_concepttoo, the exclusions having ruled its matches false positives, and so does one whose every match fails source confirmation. Underintersect = TRUEthe unit is the compound concept, so a document whose token matches do not cover every root counts as a non-case as well; the intersection is evaluated on those token matches, before the exclusions, so a document that loses a whole root to an exclusion is still delivered, carrying the roots that survived.data$matchstays pre-intersect and pre-exclusion, andanti_join(data$match, data$extract, by = id)recovers those sets.mismatchedTibble of token vs source discrepancies (token matches not confirmed in the source text). One row per match, not per document, so a document whose matches are only partly confirmed appears here and in
data$extractat once.summaryList:
token(summary by token),concept(summary by concept),params(call parameters).sheetsList:
df(data frames per Excel sheet),gt(gt tables ifsave_as_gt = TRUE).
Details
Requires edstr_config() to be called first.
Single-pass and chunked mode
Without chunk, the extraction runs in one pass over data.
With chunk, it runs over the Parquet cache edstr_clean() wrote, one
subprocess per chunk, and the results are combined into a single output: the
summary, the three saved files and every frame of the return value are what a
single-pass run over the whole corpus produces, and the per-chunk directories
are removed once the combine succeeds. A chunked run that is interrupted
resumes from the chunks already on disk unless edstr_overwrite is TRUE,
and a chunk left over from a call with other arguments aborts the combine
rather than being reused.
Four arguments change meaning between the two modes:
datais required withoutchunkand rejected with it.idandgroupare optional withoutchunkand required with it: a chunk is a range ofgroup, and each subprocess receivesid,groupand the text column alone, so neither key can be detected from that projection. Every other column is joined back after the extraction, which is what keeps a chunk's memory proportional to its text.chunk_sortis only read withchunk.
And four are rejected with chunk: sample and seed, which would draw
within each chunk rather than over the corpus, and ano_hash and ano_hide,
whose target columns no subprocess ever sees.
exclus_manual, exclus_auto_escape and exclus_auto_token_min apply
inside each subprocess, so an exclusion that fires is evaluated per chunk and
not over the corpus.
Examples
# \donttest{
edstr_config(
edstr_dirname = tempdir(), edstr_filename = "my_study",
edstr_text = "note_text", edstr_overwrite = TRUE
)
#>
#> ── edstr_config ────────────────────────────────────────────────────────────────
#>
#> ℹ Root : /home/runner/work/edstr/edstr
#>
#> ℹ Files will be saved in /tmp/RtmpQPktpQ with prefix my_study
#>
#> ────────────────────────────────────────────────────────────────────────────────
df_clean <- data.frame(
id = 1:3,
note_text = c(
"<p class=\"n\">diabete de type 2</p>",
"<p class=\"n\">bilan normal</p>",
"<p class=\"n\">tumeur mammaire</p>"
)
)
result <- edstr_extract(
data = df_clean,
concepts = c(diabete = "diabet", cancer = "cancer|tumeur")
)
#>
#> ── edstr_extract ───────────────────────────────────────────────────────────────
#> Formatting source text
#>
#> Formatting source text
#> ✔ Formatting source text [10ms]
#>
#> Tokenising source text
#>
#> Tokenising source text
#> ✔ Tokenising source text [77ms]
#>
#> Matching tokenised text
#>
#> Matching tokenised text
#> ✔ Matching tokenised text [71ms]
#>
#> Exclusions
#>
#> Exclusions
#> ✔ Exclusions [95ms]
#>
#> Matching source text
#>
#> Matching source text
#> ✔ Matching source text [80ms]
#>
#> Unmatched documents and source mismatches
#>
#> Unmatched documents and source mismatches
#> ✔ Unmatched documents and source mismatches [29ms]
#>
#> Extraction
#>
#> Extraction
#> ✔ Extraction [45ms]
#>
#> Summary
#>
#> Summary
#> ✔ Summary [100ms]
#>
#> Saving file my_study_extract.xlsx
#>
#> Saving file my_study_extract.xlsx
#> ✔ Saving file my_study_extract.xlsx [2.8s]
#>
#> Saving file my_study_extract.json
#>
#> Saving file my_study_extract.json
#> ✔ Saving file my_study_extract.json [53ms]
#>
#> Saving file my_study_extract.rds
#>
#> Saving file my_study_extract.rds
#> ✔ Saving file my_study_extract.rds [26ms]
#>
#>
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> $token
#> $token$n1
#> concept note_text match
#> 1 cancer tumeur 1
#> 2 diabete diabete 1
#>
#>
#> $count
#> $count$total
#> token concept note_text match id id_group
#> 1 1 cancer tumeur 1 1 1
#> 2 1 diabete diabete 1 1 1
#>
#> $count$token
#> token match_total match_exclus_auto match_exclus_manual match_final
#> 1 1 2 NA NA 2
#> match_distinct
#> 1 2
#>
#> $count$concepts
#> concept match_total match_exclus_auto match_exclus_manual match_final
#> 1 cancer 1 NA NA 1
#> 2 diabete 1 NA NA 1
#> match_distinct id id_group
#> 1 1 1 1
#> 2 1 1 1
#>
#> $count$exclus
#> [1] mode concept_key token concept note_text start
#> [7] end start_end match id id_group
#> <0 rows> (or 0-length row.names)
#>
#> $count$final
#> token concept note_text match id id_group
#> 1 1 cancer tumeur 1 1 1
#> 2 1 diabete diabete 1 1 1
#>
#>
#> $regex
#> $regex$concepts
#> # A tibble: 2 × 3
#> concept_key concept_name regex
#> <chr> <chr> <chr>
#> 1 diabete diabete "^(diabet)\\S*$"
#> 2 cancer cancer "^(cancer|tumeur)\\S*$"
#>
#> $regex$replace
#> # A tibble: 8 × 2
#> pattern replace
#> <chr> <chr>
#> 1 "oe" "(?:[oô][eéèêë]|œ)"
#> 2 "ae" "(?:[aàâ][eéèêë]|æ)"
#> 3 "a" "[aàâ]"
#> 4 "e" "[eéèêë]"
#> 5 "i" "[iîï]"
#> 6 "o" "[oô]"
#> 7 "u" "[uùûü]"
#> 8 "\\s" "(?:<br/>)?[\\s\\-']+(?:<br/>)?"
#>
#> $regex$final
#> # A tibble: 2 × 2
#> concept note_text
#> <chr> <glue>
#> 1 cancer (?i)\b(t[uùûü]m[eéèêë][uùûü]r)\b
#> 2 diabete (?i)\b(d[iîï][aàâ]b[eéèêë]t[eéèêë])\b
#>
#> $regex$match
#> # A tibble: 2 × 3
#> id concept match
#> <int> <chr> <chr>
#> 1 3 cancer tumeur
#> 2 1 diabete diabete
#>
#>
#> $unmatched
#> $unmatched$n_no_concept
#> [1] 1
#>
#> $unmatched$no_concept
#> [1] id id_group
#> <0 rows> (or 0-length row.names)
#>
#> $unmatched$no_source
#> [1] id id_group
#> <0 rows> (or 0-length row.names)
#>
#> $unmatched$empty_text
#> [1] id id_group
#> <0 rows> (or 0-length row.names)
#>
#> $unmatched$outside_p
#> [1] id id_group
#> <0 rows> (or 0-length row.names)
#>
#>
#> $mismatched
#> [1] id concept match
#> <0 rows> (or 0-length row.names)
#>
#> $params
#> # A tibble: 18 × 2
#> arg value
#> <chr> <chr>
#> 1 sample NULL
#> 2 seed NULL
#> 3 id id
#> 4 group NULL
#> 5 ngram_max 1
#> 6 concepts_root diabete ; cancer
#> 7 concepts_names diabete ; cancer
#> 8 collapse FALSE
#> 9 intersect FALSE
#> 10 starts_with_only TRUE
#> 11 exclus_manual NULL
#> 12 exclus_auto_escape NULL
#> 13 exclus_auto_token_min 10
#> 14 regex_replace NULL
#> 15 unmatched_data FALSE
#> 16 concept_color #0099FF
#> 17 text_color #FF0000
#> 18 save_as_gt FALSE
#>
#> ────────────────────────────────────────────────────────────────────────────────
#>
#>
#> ── Timing per step ──
#>
#> Parse concepts: 0.004 sec elapsed
#> Check ids: 0.006 sec elapsed
#> Anonymise: 0 sec elapsed
#> Format text: 0.002 sec elapsed
#> Tokenise: 0.06 sec elapsed
#> Match token: 0.053 sec elapsed
#> Exclusions: 0.076 sec elapsed
#> Match source: 0.055 sec elapsed
#> Unmatched: 0.01 sec elapsed
#> Extraction: 0.028 sec elapsed
#> Summary: 0.073 sec elapsed
#> Build sheets: 0.008 sec elapsed
#> Build gt: 0 sec elapsed
#> Build xlsx: 2.568 sec elapsed
#> Save xlsx: 0.257 sec elapsed
#> Save json: 0.032 sec elapsed
#> Note markup: 0.005 sec elapsed
#> Save rds: 0.001 sec elapsed
#>
#> Full steps: 3.497 sec elapsed
#>
#> ℹ Documents
#> • Total: 3 id
#>
#> ℹ Total matches
#> • 2 across 2 id (66.7% id)
#> • No exclusions
#>
#> ℹ Unmatched (no concept): 1 id
#>
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ✔ 2 root concepts: diabete, cancer
#> ✔ 2 matches
#> • 2 id (66.7% id)
#>
#> ✔ 1 unmatched (no concept)
#> • 1 id (33.3% id)
#>
#> ✔ 2 distinct matches
#>
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ℹ Root directory: /home/runner/work/edstr/edstr
#> ✔ Files saved in /tmp/RtmpQPktpQ/extract
#>
#> ────────────────────────────────────────────────────────────────────────────────
# }