Adding Rule-Based Named Entities
Kenneth Benoit
2026-11-10
Source:vignettes/articles/entity_rules.Rmd
entity_rules.RmdIntroduction
spaCy’s language models recognise named entities statistically, from
what they learned in training. They often miss entities that matter for
a particular study: names specific to a field, organisations the model
has never seen, or entities that the model’s training data marked
differently from how a researcher would. A common example (#234) is
people’s academic titles, which spaCy’s English models leave out of a
PERSON entity.
spaCy solves this with an entity ruler, a pipeline component that adds entities by matching rules: phrases, or sequences of tokens described by their attributes. spacyr lets you create these rules in R, and adds them to the pipeline when it initializes spaCy:
-
as_entity_patterns()creates a set of rules from phrases, spaCy’s own pattern structure, a data.frame, a quanteda dictionary, or entities that spaCy has already found; -
entity_patterns_read()andentity_patterns_write()read and write rules as files, in spaCy’s JSONL format; -
spacy_initialize(entity_patterns = )adds the rules to the pipeline.
Creating rules needs neither Python nor spaCy, so the rules are ordinary R objects that can be inspected, combined and saved.
If spaCy is already running in your R session, call
spacy_finalize() before calling
spacy_initialize() with a new set of rules.
Phrases
The simplest rule is a phrase with a label. The names of a character vector give the labels:
pats <- as_entity_patterns(c(ORG = "Acme Widgets", GPE = "Ruritania"))
pats
## Entity patterns: 2 patterns (ORG: 1, GPE: 1)
## ORG "Acme Widgets"
## GPE "Ruritania"Without rules, the model finds some of these entities, but not as we would label them:
txt <- "Acme Widgets opened a factory in Ruritania, said Jane Smith."
spacy_initialize()
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity(txt)
## doc_id text ent_type start_id length
## 1 text1 Acme Widgets PERSON 1 2
## 2 text1 Ruritania GPE 7 1
## 3 text1 Jane Smith PERSON 10 2
spacy_finalize()With the rules, it finds both, with our labels, and still finds the entities that the rules say nothing about:
spacy_initialize(entity_patterns = pats)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity(txt)
## doc_id text ent_type start_id length
## 1 text1 Acme Widgets ORG 1 2
## 2 text1 Ruritania GPE 7 1
## 3 text1 Jane Smith PERSON 10 2
spacy_parse(txt, pos = FALSE, lemma = FALSE)
## doc_id sentence_id token_id token entity
## 1 text1 1 1 Acme ORG_B
## 2 text1 1 2 Widgets ORG_I
## 3 text1 1 3 opened
## 4 text1 1 4 a
## 5 text1 1 5 factory
## 6 text1 1 6 in
## 7 text1 1 7 Ruritania GPE_B
## 8 text1 1 8 ,
## 9 text1 1 9 said
## 10 text1 1 10 Jane PERSON_B
## 11 text1 1 11 Smith PERSON_I
## 12 text1 1 12 .
spacy_finalize()Phrases match spaCy’s tokens exactly, including their case. With
case_insensitive = TRUE, they match in any case:
pats <- as_entity_patterns(c("Acme Widgets", "Globex"), label = "ORG",
case_insensitive = TRUE)
pats
## Entity patterns: 2 patterns (ORG: 2)
## ORG "Acme Widgets" (any case)
## ORG "Globex" (any case)
spacy_initialize(entity_patterns = pats)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity("ACME WIDGETS and globex announced a merger.")
## doc_id text ent_type start_id length
## 1 text1 ACME WIDGETS ORG 1 2
## 2 text1 globex ORG 4 1
spacy_finalize()A phrase is split into tokens by the pipeline’s own tokenizer, so
"Globex, Inc." is matched as the three tokens
Globex, , and Inc., as in a text.
Some rules of the tokenizer depend on case, though: spaCy’s English
models keep "Inc." as one token, but split
"inc." into "inc" and ".". So
write phrases in their usual case, even when they should match in any
case.
Token patterns
Rules can also describe a sequence of tokens by their attributes, in
spaCy’s own
structure: a list with one element per token, each a named list of
attributes such as LOWER (the token in lower case),
IS_TITLE, ENT_TYPE (the type of entity a token
belongs to) or OP (how many times the token may occur).
To include academic titles in PERSON entities, we match
a title, followed by the tokens of a person that the model has already
recognised:
titles <- c("dr", "dr.", "prof", "prof.", "professor")
title_rule <- as_entity_patterns(list(
label = "PERSON",
pattern = list(list(LOWER = list(IN = titles)),
list(ENT_TYPE = "PERSON", ENT_IOB = "B"),
list(ENT_TYPE = "PERSON", ENT_IOB = "I", OP = "*"))
))
title_rule
## Entity patterns: 1 pattern (PERSON: 1)
## PERSON [LOWER in (dr, dr., prof, prof., professor)] [ENT_TYPE = PERSON, ENT_IOB = B] [ENT_TYPE = PERSON, ENT_IOB = I]*The second token begins a PERSON entity
(ENT_IOB = "B"), and the third matches the rest of that
entity (ENT_IOB = "I"), zero or more times. A simpler rule,
a title followed by list(ENT_TYPE = "PERSON", OP = "+"),
would also match, but it would run on into a second person who directly
follows the first, joining “Prof. Jane Smith” and “Bob Jones” into one
entity. Both titles need to be listed with and without a full stop,
since the English models keep “Prof.” as one token.
txt <- "Prof. Jane Smith Bob Jones and Dr. Kim Park spoke."
spacy_initialize()
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity(txt)
## doc_id text ent_type start_id length
## 1 text1 Jane Smith PERSON 2 2
## 2 text1 Bob Jones PERSON 4 2
## 3 text1 Kim Park PERSON 8 2
spacy_finalize()
spacy_initialize(entity_patterns = title_rule)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity(txt)
## doc_id text ent_type start_id length
## 1 text1 Prof. Jane Smith PERSON 1 3
## 2 text1 Bob Jones PERSON 4 2
## 3 text1 Dr. Kim Park PERSON 7 3
spacy_finalize()as_entity_patterns() checks the structure of token
patterns, and converts them to the form spaCy expects: for instance, the
values of IN stay a list even when there is only one. spaCy
checks the attribute names and values when the rules are added, and
spacy_initialize() shows its message if it does not accept
them.
Rules and the model
spacyr adds its entity ruler at the end of the
pipeline, after the model’s named entity recognizer and anything else
that sets entities. This is what allows the title rule to build on the
model’s PERSON entities.
Where a rule’s entity overlaps one that the model found, the rule’s
entity is kept. With overwrite_entities = FALSE, the
model’s entity is kept instead, so that the rules only add entities
where the model found none:
txt <- "Jane Smith flew to Paris."
spacy_initialize(entity_patterns = c(LOC = "Paris"))
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity(txt)
## doc_id text ent_type start_id length
## 1 text1 Jane Smith PERSON 1 2
## 2 text1 Paris LOC 5 1
spacy_finalize()
spacy_initialize(entity_patterns = c(LOC = "Paris"), overwrite_entities = FALSE)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity(txt)
## doc_id text ent_type start_id length
## 1 text1 Jane Smith PERSON 1 2
## 2 text1 Paris GPE 5 1
spacy_finalize()When two rules overlap, spaCy prefers the match that spans more tokens. If they span the same number of tokens, the one that starts earlier takes priority. See spaCy’s entity ruler guide.
Set entity = FALSE to disable the model’s named entity
recogniser (ner). Rules based on token attributes such as
IS_TITLE still work:
name_rule <- as_entity_patterns(list(
label = "NAME",
pattern = list(list(IS_TITLE = TRUE), list(IS_TITLE = TRUE))
))
spacy_initialize(entity = FALSE, entity_patterns = name_rule)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_parse("We met Jane Smith there.", pos = FALSE, lemma = FALSE)
## doc_id sentence_id token_id token entity
## 1 text1 1 1 We
## 2 text1 1 2 met
## 3 text1 1 3 Jane NAME_B
## 4 text1 1 4 Smith NAME_I
## 5 text1 1 5 there
## 6 text1 1 6 .
spacy_finalize()The title rule relies on entity annotations (ENT_TYPE
and ENT_IOB) from an earlier component. With
ner disabled and no other component supplying entities,
spacy_initialize() warns that the title rule will not
match:
spacy_initialize(entity = FALSE, entity_patterns = title_rule)
## Warning: Some patterns need entities that an earlier component found (ENT_TYPE
## or ENT_IOB), but no component before spacyr's entity ruler sets entities, so
## they will not match; use spacy_initialize(entity = TRUE)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_finalize()A saved ruler stays in the pipeline and can supply entities even with
entity = FALSE. spacyr adds its own ruler
after it, using spacyr_entity_ruler or a numbered variant
if that name is already in use.
Dictionaries
A quanteda dictionary becomes a set of rules
labelled by its top-level keys. As in quanteda, its
values match in any case, and they can have wildcards: *
for any characters, and ? for one. A value with wildcards
matches one token for each part separated by spaces, as
quanteda matches dictionary values to tokens, so
"dr * who" matches three tokens.
library("quanteda")
dict <- dictionary(list(
ORG = c("Acme Widgets", "Globex*", "Initech, Inc."),
PERSON = "dr * who"
), tolower = FALSE)
as_entity_patterns(dict)
## Entity patterns: 4 patterns (ORG: 3, PERSON: 1)
## ORG "Acme Widgets" (any case)
## ORG [LOWER ~ /^globex.*$/]
## ORG "Initech, Inc." (any case)
## PERSON [LOWER = dr] [LOWER ~ /^.*$/] [LOWER = who]dictionary() lowercases its values by default, so this
one is made with tolower = FALSE, to keep
"Inc." as spaCy’s tokenizer expects it. Here the model’s
recognizer is off, so that all the entities come from the
dictionary:
spacy_initialize(entity = FALSE, entity_patterns = dict)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity("ACME WIDGETS and Globexcorp met Dr Hal Who at INITECH, Inc.")
## doc_id text ent_type start_id length
## 1 text1 ACME WIDGETS ORG 1 2
## 2 text1 Globexcorp ORG 4 1
## 3 text1 Dr Hal Who PERSON 6 3
## 4 text1 INITECH, Inc. ORG 10 3
spacy_finalize()With valuetype = "regex", each part of a value is a
regular expression for one token. These are regular expressions in the
dialect of Python, which differs from that of R in some details: for
instance, Python does not support "\\p{L}" for letters.
Make such a dictionary with tolower = FALSE, so that
escapes such as "\\D" keep their case.
Reusing the entities found
The entities that spaCy finds in one set of texts can become rules
for another, for instance to make sure that the entities found in a
carefully reviewed corpus are found again, or to carry entities over to
a model for another language. The output of
spacy_extract_entity() keeps the text of each entity as it
was, and as_entity_patterns() makes a phrase of each:
spacy_initialize()
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
found <- spacy_extract_entity(c(
"Acme Widgets hired Jane Smith.",
"Jane Smith moved to Ruritania."
))
spacy_finalize()
found
## doc_id text ent_type start_id length
## 1 text1 Acme Widgets PERSON 1 2
## 2 text1 Jane Smith PERSON 4 2
## 3 text2 Jane Smith PERSON 1 2
## 4 text2 Ruritania GPE 5 1The model took “Acme Widgets” for a person. Reviewing the entities before reusing them lets us correct that, and keep only the types we want, here people and organisations. Duplicates, such as the second “Jane Smith”, are dropped:
found$ent_type[found$text == "Acme Widgets"] <- "ORG"
found <- found[found$ent_type %in% c("PERSON", "ORG"), ]
pats <- as_entity_patterns(found)
pats
## Entity patterns: 2 patterns (ORG: 1, PERSON: 1)
## ORG "Acme Widgets"
## PERSON "Jane Smith"
spacy_initialize(entity = FALSE, entity_patterns = pats)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
spacy_extract_entity("Later, Jane Smith left Acme Widgets.")
## doc_id text ent_type start_id length
## 1 text1 Jane Smith PERSON 3 2
## 2 text1 Acme Widgets ORG 6 2
spacy_finalize()entity_extract(), which works on the output of
spacy_parse(), joins the tokens of each entity with an
underscore. as_entity_patterns() converts its output too,
but needs to be told the concatenator, since a token that
itself contains one would be changed.
spacy_extract_entity() is the better source.
Combining and saving rules
Sets of rules combine with c(), and are data.frames,
with one row per rule:
all_rules <- c(title_rule, as_entity_patterns(c(ORG = "Acme Widgets", GPE = "Ruritania")))
all_rules
## Entity patterns: 3 patterns (PERSON: 1, ORG: 1, GPE: 1)
## PERSON [LOWER in (dr, dr., prof, prof., professor)] [ENT_TYPE = PERSON, ENT_IOB = B] [ENT_TYPE = PERSON, ENT_IOB = I]*
## ORG "Acme Widgets"
## GPE "Ruritania"
as.data.frame(all_rules)[, c("label", "id", "case_insensitive")]
## label id case_insensitive
## 1 PERSON <NA> NA
## 2 ORG <NA> FALSE
## 3 GPE <NA> FALSEentity_patterns_write() saves them in spaCy’s JSONL
format, with one rule per line, which spaCy can also load on its own,
and entity_patterns_read() reads them back:
file <- tempfile(fileext = ".jsonl")
entity_patterns_write(all_rules, file)
cat(readLines(file), sep = "\n")
## {"label":"PERSON","pattern":[{"LOWER":{"IN":["dr","dr.","prof","prof.","professor"]}},{"ENT_TYPE":"PERSON","ENT_IOB":"B"},{"ENT_TYPE":"PERSON","ENT_IOB":"I","OP":"*"}]}
## {"label":"ORG","pattern":"Acme Widgets"}
## {"label":"GPE","pattern":"Ruritania"}
identical(entity_patterns_read(file), all_rules)
## [1] TRUEspacyr preserves case_insensitive when writing and
reading JSONL. Native spaCy ignores this field, so its default ruler
matches those phrases case-sensitively.
entity_patterns_read() also reads a JSON array of rules,
or a text file with one phrase per line, given a label.
Speed
On a test with 5,000 sentences, 10,000 phrases added little parsing time when all used the same case-matching setting. spacyr can also combine simple single-token regular expressions, such as dictionary globs, when they share a label, ID and token attribute. Other token patterns are checked one by one. Mixing case-sensitive and case-insensitive phrases also makes large rule sets slower, because the case-insensitive phrases are matched as token patterns.