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,
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,
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.- 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.- 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.
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/RtmpbqjexN 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 [9ms]
#>
#> Tokenising source text
#>
#> Tokenising source text
#> ✔ Tokenising source text [22ms]
#>
#> Matching tokenised text
#>
#> Matching tokenised text
#> ✔ Matching tokenised text [67ms]
#>
#> Exclusions
#>
#> Exclusions
#> ✔ Exclusions [93ms]
#>
#> Matching source text
#>
#> Matching source text
#> ✔ Matching source text [66ms]
#>
#> Unmatched documents and source mismatches
#>
#> Unmatched documents and source mismatches
#> ✔ Unmatched documents and source mismatches [26ms]
#>
#> Extraction
#>
#> Extraction
#> ✔ Extraction [42ms]
#>
#> Summary
#>
#> Summary
#> ✔ Summary [123ms]
#>
#> Saving file my_study_extract.xlsx
#>
#> Saving file my_study_extract.xlsx
#> ✔ Saving file my_study_extract.xlsx [2.5s]
#>
#> Saving file my_study_extract.json
#>
#> Saving file my_study_extract.json
#> ✔ Saving file my_study_extract.json [49ms]
#>
#> Saving file my_study_extract.rds
#>
#> Saving file my_study_extract.rds
#> ✔ Saving file my_study_extract.rds [25ms]
#>
#>
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> $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.003 sec elapsed
#> Check ids: 0.007 sec elapsed
#> Anonymise: 0 sec elapsed
#> Format text: 0.002 sec elapsed
#> Tokenise: 0.005 sec elapsed
#> Match token: 0.05 sec elapsed
#> Exclusions: 0.076 sec elapsed
#> Match source: 0.049 sec elapsed
#> Unmatched: 0.009 sec elapsed
#> Extraction: 0.025 sec elapsed
#> Summary: 0.098 sec elapsed
#> Build sheets: 0.008 sec elapsed
#> Build gt: 0 sec elapsed
#> Build xlsx: 2.4 sec elapsed
#> Save xlsx: 0.059 sec elapsed
#> Save json: 0.031 sec elapsed
#> Note markup: 0.005 sec elapsed
#> Save rds: 0.001 sec elapsed
#>
#> Full steps: 3.065 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/RtmpbqjexN/extract
#>
#> ────────────────────────────────────────────────────────────────────────────────
# }