spaCy/spacy/matcher/phrasematcher.pyx

345 lines
13 KiB
Cython
Raw Normal View History

# cython: infer_types=True
# cython: profile=True
from __future__ import unicode_literals
2019-09-24 12:39:50 +00:00
from libc.stdint cimport uintptr_t
from libc.stdio cimport printf
from libcpp.vector cimport vector
from cymem.cymem cimport Pool
from preshed.maps cimport MapStruct, map_init, map_set, map_get, map_clear
from preshed.maps cimport map_iter, key_t
2019-09-24 12:39:50 +00:00
from ..attrs cimport ORTH, POS, TAG, DEP, LEMMA, attr_id_t
from ..vocab cimport Vocab
from ..structs cimport TokenC
from ..tokens.token cimport Token
from ..tokens.doc cimport Doc, get_token_attr
2019-08-21 12:00:37 +00:00
from ._schemas import TOKEN_PATTERN_SCHEMA
from ..errors import Errors, Warnings, deprecation_warning, user_warning
cdef class PhraseMatcher:
"""Efficiently match large terminology lists. While the `Matcher` matches
sequences based on lists of token descriptions, the `PhraseMatcher` accepts
match patterns in the form of `Doc` objects.
DOCS: https://spacy.io/api/phrasematcher
USAGE: https://spacy.io/usage/rule-based-matching#phrasematcher
Adapted from FlashText: https://github.com/vi3k6i5/flashtext
MIT License (see `LICENSE`)
Copyright (c) 2017 Vikash Singh (vikash.duliajan@gmail.com)
"""
cdef Vocab vocab
cdef attr_id_t attr
cdef object _callbacks
cdef object _keywords
2019-09-19 18:20:53 +00:00
cdef object _docs
cdef bint _validate
2019-09-24 12:39:50 +00:00
cdef MapStruct* c_map
cdef Pool mem
cdef key_t _terminal_hash
2019-09-24 12:39:50 +00:00
cdef void find_matches(self, Doc doc, vector[MatchStruct] *matches) nogil
2019-09-24 12:39:50 +00:00
def __init__(self, Vocab vocab, max_length=0, attr="ORTH", validate=False):
"""Initialize the PhraseMatcher.
vocab (Vocab): The shared vocabulary.
attr (int / unicode): Token attribute to match on.
validate (bool): Perform additional validation when patterns are added.
RETURNS (PhraseMatcher): The newly constructed object.
DOCS: https://spacy.io/api/phrasematcher#init
"""
if max_length != 0:
deprecation_warning(Warnings.W010)
self.vocab = vocab
self._callbacks = {}
self._keywords = {}
2019-09-19 18:20:53 +00:00
self._docs = {}
self._validate = validate
2019-09-24 12:39:50 +00:00
self.mem = Pool()
self.c_map = <MapStruct*>self.mem.alloc(1, sizeof(MapStruct))
self._terminal_hash = 826361138722620965
2019-09-24 12:39:50 +00:00
map_init(self.mem, self.c_map, 8)
if isinstance(attr, long):
self.attr = attr
else:
2019-08-21 12:00:37 +00:00
attr = attr.upper()
if attr == "TEXT":
attr = "ORTH"
if attr not in TOKEN_PATTERN_SCHEMA["items"]["properties"]:
raise ValueError(Errors.E152.format(attr=attr))
self.attr = self.vocab.strings[attr]
def __len__(self):
"""Get the number of match IDs added to the matcher.
RETURNS (int): The number of rules.
DOCS: https://spacy.io/api/phrasematcher#len
"""
return len(self._callbacks)
def __contains__(self, key):
"""Check whether the matcher contains rules for a match ID.
key (unicode): The match ID.
RETURNS (bool): Whether the matcher contains rules for this match ID.
DOCS: https://spacy.io/api/phrasematcher#contains
"""
return key in self._callbacks
2019-09-19 18:20:53 +00:00
def __reduce__(self):
data = (self.vocab, self._docs, self._callbacks)
return (unpickle_matcher, data, None, None)
def remove(self, key):
"""Remove a match-rule from the matcher by match ID.
key (unicode): The match ID.
"""
if key not in self._keywords:
return
2019-09-24 12:39:50 +00:00
cdef MapStruct* current_node
cdef MapStruct* terminal_map
cdef MapStruct* node_pointer
cdef void* result
2019-09-24 12:39:50 +00:00
cdef key_t terminal_key
cdef void* value
cdef int c_i = 0
for keyword in self._keywords[key]:
2019-09-24 12:39:50 +00:00
current_node = self.c_map
token_trie_list = []
2019-09-24 12:39:50 +00:00
for token in keyword:
result = map_get(current_node, token)
if result:
2019-09-24 12:39:50 +00:00
token_trie_list.append((token, <uintptr_t>current_node))
current_node = <MapStruct*>result
else:
# if token is not found, break out of the loop
2019-09-24 12:39:50 +00:00
current_node = NULL
break
2019-09-24 12:39:50 +00:00
# remove the tokens from trie node if there are no other
# keywords with them
result = map_get(current_node, self._terminal_hash)
if current_node != NULL and result:
# if this is the only remaining key, remove unnecessary paths
terminal_map = <MapStruct*>result
2019-09-24 12:39:50 +00:00
terminal_keys = []
c_i = 0
while map_iter(terminal_map, &c_i, &terminal_key, &value):
terminal_keys.append(self.vocab.strings[terminal_key])
# TODO: not working, fix remove for unused paths/maps
if False and terminal_keys == [key]:
# we found a complete match for input keyword
2019-09-24 12:39:50 +00:00
token_trie_list.append((self.vocab.strings[key], <uintptr_t>terminal_map))
token_trie_list.reverse()
2019-09-24 12:39:50 +00:00
for key_to_remove, py_node_pointer in token_trie_list:
node_pointer = <MapStruct*>py_node_pointer
result = map_get(node_pointer, key_to_remove)
2019-09-24 12:39:50 +00:00
if node_pointer.filled == 1:
map_clear(node_pointer, key_to_remove)
self.mem.free(result)
2019-09-24 12:39:50 +00:00
pass
else:
# more than one key means more than 1 path,
# delete not required path and keep the other
2019-09-24 12:39:50 +00:00
map_clear(node_pointer, key_to_remove)
self.mem.free(result)
break
# otherwise simply remove the key
else:
result = map_get(current_node, self._terminal_hash)
if result:
map_clear(<MapStruct*>result, self.vocab.strings[key])
del self._keywords[key]
del self._callbacks[key]
2019-09-19 18:20:53 +00:00
del self._docs[key]
def add(self, key, on_match, *docs):
"""Add a match-rule to the phrase-matcher. A match-rule consists of: an ID
key, an on_match callback, and one or more patterns.
key (unicode): The match ID.
on_match (callable): Callback executed on match.
*docs (Doc): `Doc` objects representing match patterns.
DOCS: https://spacy.io/api/phrasematcher#add
"""
2019-09-24 14:07:38 +00:00
_ = self.vocab[key]
self._callbacks[key] = on_match
self._keywords.setdefault(key, [])
2019-09-19 18:20:53 +00:00
self._docs.setdefault(key, set())
self._docs[key].update(docs)
2019-09-24 12:39:50 +00:00
cdef MapStruct* current_node
cdef MapStruct* internal_node
cdef void* result
2019-09-24 12:39:50 +00:00
for doc in docs:
if len(doc) == 0:
continue
2019-08-21 12:00:37 +00:00
if self.attr in (POS, TAG, LEMMA) and not doc.is_tagged:
raise ValueError(Errors.E155.format())
if self.attr == DEP and not doc.is_parsed:
raise ValueError(Errors.E156.format())
if self._validate and (doc.is_tagged or doc.is_parsed) \
and self.attr not in (DEP, POS, TAG, LEMMA):
string_attr = self.vocab.strings[self.attr]
user_warning(Warnings.W012.format(key=key, attr=string_attr))
keyword = self._convert_to_array(doc)
# keep track of keywords per key to make remove easier
# (would use a set, but can't hash numpy arrays)
self._keywords[key].append(keyword)
2019-09-24 12:39:50 +00:00
current_node = self.c_map
for token in keyword:
if token == self._terminal_hash:
user_warning(Warnings.W021)
break
result = <MapStruct*>map_get(current_node, token)
if not result:
2019-09-24 12:39:50 +00:00
internal_node = <MapStruct*>self.mem.alloc(1, sizeof(MapStruct))
map_init(self.mem, internal_node, 8)
map_set(self.mem, current_node, token, internal_node)
result = internal_node
current_node = <MapStruct*>result
result = <MapStruct*>map_get(current_node, self._terminal_hash)
if not result:
2019-09-24 12:39:50 +00:00
internal_node = <MapStruct*>self.mem.alloc(1, sizeof(MapStruct))
map_init(self.mem, internal_node, 8)
map_set(self.mem, current_node, self._terminal_hash, internal_node)
result = internal_node
map_set(self.mem, <MapStruct*>result, self.vocab.strings[key], NULL)
def __call__(self, doc):
"""Find all sequences matching the supplied patterns on the `Doc`.
doc (Doc): The document to match over.
RETURNS (list): A list of `(key, start, end)` tuples,
describing the matches. A match tuple describes a span
`doc[start:end]`. The `label_id` and `key` are both integers.
DOCS: https://spacy.io/api/phrasematcher#call
"""
matches = []
if doc is None or len(doc) == 0:
# if doc is empty or None just return empty list
return matches
2019-09-24 12:39:50 +00:00
cdef vector[MatchStruct] c_matches
self.find_matches(doc, &c_matches)
2019-09-24 12:39:50 +00:00
for i in range(c_matches.size()):
matches.append((c_matches[i].match_id, c_matches[i].start, c_matches[i].end))
for i, (ent_id, start, end) in enumerate(matches):
on_match = self._callbacks.get(ent_id)
if on_match is not None:
on_match(self, doc, i, matches)
return matches
cdef void find_matches(self, Doc doc, vector[MatchStruct] *matches) nogil:
2019-09-24 12:39:50 +00:00
cdef MapStruct* current_node = self.c_map
cdef int start = 0
cdef int idx = 0
cdef int idy = 0
cdef key_t key
cdef void* value
cdef int i = 0
cdef MatchStruct ms
cdef void* result
while idx < doc.length:
start = idx
token = Token.get_struct_attr(&doc.c[idx], self.attr)
# look for sequences from this position
result = map_get(current_node, token)
if result:
current_node = <MapStruct*>result
idy = idx + 1
while idy < doc.length:
result = map_get(current_node, self._terminal_hash)
if result:
2019-09-24 12:39:50 +00:00
i = 0
while map_iter(<MapStruct*>result, &i, &key, &value):
2019-09-24 12:39:50 +00:00
ms = make_matchstruct(key, start, idy)
matches.push_back(ms)
inner_token = Token.get_struct_attr(&doc.c[idy], self.attr)
result = map_get(current_node, inner_token)
if result:
current_node = <MapStruct*>result
idy += 1
else:
break
else:
# end of doc reached
result = map_get(current_node, self._terminal_hash)
if result:
2019-09-24 12:39:50 +00:00
i = 0
while map_iter(<MapStruct*>result, &i, &key, &value):
2019-09-24 12:39:50 +00:00
ms = make_matchstruct(key, start, idy)
matches.push_back(ms)
current_node = self.c_map
idx += 1
def pipe(self, stream, batch_size=1000, n_threads=-1, return_matches=False,
as_tuples=False):
"""Match a stream of documents, yielding them in turn.
docs (iterable): A stream of documents.
batch_size (int): Number of documents to accumulate into a working set.
return_matches (bool): Yield the match lists along with the docs, making
results (doc, matches) tuples.
as_tuples (bool): Interpret the input stream as (doc, context) tuples,
and yield (result, context) tuples out.
If both return_matches and as_tuples are True, the output will
be a sequence of ((doc, matches), context) tuples.
YIELDS (Doc): Documents, in order.
DOCS: https://spacy.io/api/phrasematcher#pipe
"""
if n_threads != -1:
deprecation_warning(Warnings.W016)
if as_tuples:
for doc, context in stream:
matches = self(doc)
if return_matches:
yield ((doc, matches), context)
else:
yield (doc, context)
else:
for doc in stream:
matches = self(doc)
if return_matches:
yield (doc, matches)
else:
yield doc
def _convert_to_array(self, Doc doc):
return [Token.get_struct_attr(&doc.c[i], self.attr) for i in range(len(doc))]
2019-09-19 18:20:53 +00:00
def unpickle_matcher(vocab, docs, callbacks):
matcher = PhraseMatcher(vocab)
for key, specs in docs.items():
callback = callbacks.get(key, None)
matcher.add(key, callback, *specs)
return matcher
2019-09-24 12:39:50 +00:00
cdef MatchStruct make_matchstruct(key_t match_id, int start, int end) nogil:
cdef MatchStruct ms
ms.match_id = match_id
ms.start = start
ms.end = end
return ms