From 6c91636d3d5779520e89ebb57947582623b8a62a Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sun, 19 Feb 2023 11:51:26 -0300 Subject: [PATCH 01/23] fix documentation --- src/iamsystem/matcher/api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/iamsystem/matcher/api.py b/src/iamsystem/matcher/api.py index 8f47069..e1db5a0 100644 --- a/src/iamsystem/matcher/api.py +++ b/src/iamsystem/matcher/api.py @@ -7,6 +7,7 @@ from typing_extensions import runtime_checkable from iamsystem.fuzzy.api import ISynsProvider +from iamsystem.keywords.api import IKeyword from iamsystem.stopwords.api import IStopwords from iamsystem.tokenization.api import IOffsets from iamsystem.tokenization.api import ISpan @@ -27,7 +28,8 @@ def algos(self) -> List[List[str]]: @property def stop_tokens(self) -> List[TokenT]: - """Access brat formatter.""" + """The list of stopwords tokens inside the annotation detected by + the Matcher stopwords instance""" raise NotImplementedError @property @@ -36,7 +38,7 @@ def brat_formatter(self) -> "IBratFormatter": raise NotImplementedError @property - def keywords(self): # + def keywords(self) -> Sequence[IKeyword]: """Keywords linked to this annotation.""" raise NotImplementedError From 5836e63ea2414eddf554e2e7c1da065be0f7b94a Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 25 Feb 2023 12:01:00 -0300 Subject: [PATCH 02/23] #11 add a (failing) test to show the current behavior of duplicated states that generate a lot of overlaps --- tests/test_matcher.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 763ef5d..b3095e2 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -194,6 +194,7 @@ def test_negative_stopwords(self): self.assertEqual(1, len(annots)) def test_keywords_iterator(self): + """Test it's possible to iterate over keywords.""" matcher = Matcher() termino = Terminology() ent = Entity(label="ulcères gastriques", kb_id="K25") @@ -203,6 +204,18 @@ def test_keywords_iterator(self): annots = matcher.annot_text(text="ulcères gastriques") self.assertEqual(1, len(annots)) + def test_duplicate_states_generate_lot_of_overlaps(self): + """https://github.com/scossin/iamsystem_python/issues/11 + If the algorithm takes all possible paths then it outputs 16 + annotations. By storing algorithms' states in a set rather than in + an array, an existing state is replaced. + """ + matcher = Matcher.build(keywords=["cancer de la prostate"], w=3) + annots = matcher.annot_text( + text="cancer cancer de de la la prostate prostate" + ) + self.assertEqual(len(annots), 1) + class AnotherFuzzyAlgo(NormLabelAlgo): """A fuzzy algorithm that returns always the same sequence of tokens.""" From a739a9918bfe2135a0da5cc99118ca5194c84c5f Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 25 Feb 2023 12:04:20 -0300 Subject: [PATCH 03/23] fix tests documentation --- tests/test_brat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_brat.py b/tests/test_brat.py index 15faf28..cba660a 100644 --- a/tests/test_brat.py +++ b/tests/test_brat.py @@ -82,6 +82,7 @@ def test_bad_entity_id(self): ) def test_to_brat_format(self): + """to_brat_format function performs a per token annotation.""" matcher = Matcher.build( keywords=["cancer prostate"], stopwords=["de", "la"], w=2 ) @@ -92,6 +93,7 @@ def test_to_brat_format(self): ) def test_to_brat_format_leading_stop(self): + """Leading stopwords are removed from a discontinuous sequence.""" matcher = Matcher.build( keywords=["cancer prostate"], stopwords=["de", "la"], w=2 ) @@ -133,6 +135,7 @@ class MyEntity(Keyword): brat_type: str def __str__(self): + """Concatenate the label and brat type for the test.""" return f"{self.label} ({self.brat_type})" From 83dd0a03da6eb5bfecf8531b77805306d05b01cb Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sun, 26 Feb 2023 12:33:30 -0300 Subject: [PATCH 04/23] fix #11 replace array of states by a set of states to avoid duplicates states that generate numeros overlapping annotations --- src/iamsystem/fuzzy/api.py | 43 ++++------- src/iamsystem/fuzzy/cache.py | 5 +- src/iamsystem/matcher/annotation.py | 26 ++++--- src/iamsystem/matcher/matcher.py | 115 ++++++++++++++++------------ src/iamsystem/matcher/util.py | 67 ++++++++++------ tests/test_annotation.py | 42 ++++++++++ tests/test_detect.py | 4 +- tests/test_matcher.py | 18 +++++ tests/utils_detector.py | 32 +++++--- 9 files changed, 231 insertions(+), 121 deletions(-) diff --git a/src/iamsystem/fuzzy/api.py b/src/iamsystem/fuzzy/api.py index 30afad2..ecdb361 100644 --- a/src/iamsystem/fuzzy/api.py +++ b/src/iamsystem/fuzzy/api.py @@ -8,6 +8,7 @@ from typing import List from typing import Optional from typing import Sequence +from typing import Set from typing import Tuple from typing_extensions import Protocol @@ -15,9 +16,8 @@ from iamsystem.fuzzy.util import IWords2ignore from iamsystem.fuzzy.util import SimpleWords2ignore -from iamsystem.matcher.util import IState +from iamsystem.matcher.util import LinkedState from iamsystem.tokenization.api import TokenT -from iamsystem.tree.nodes import INode # Synonym type. Ex: ('insuffisance','cardiaque') @@ -39,8 +39,8 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - w_states: List[List[IState]], - ) -> Iterable[SynAlgos]: + states: Set[LinkedState], + ) -> List[SynAlgos]: """Retrieve the synonyms of a token. :param tokens: the sequence of tokens of the document. @@ -48,7 +48,7 @@ def get_synonyms( around the token of interest given by 'i' parameter. :param token: the token of this sequence for which synonyms are expected. - :param w_states: the states in which the algorithm currently is. + :param states: the states in which the algorithm currently is. Useful is the fuzzy algorithm needs to know the current states and the possible state transitions. :return: 0 to many synonyms. @@ -59,7 +59,7 @@ def get_synonyms( class FuzzyAlgo(Generic[TokenT], ABC): """Fuzzy Algorithm base class.""" - NO_SYN: Iterable[SynType] = [] # + NO_SYN: Iterable[SynType] = [] "Default value to return by a fuzzy algorithm if no synonym found." def __init__(self, name: str): @@ -96,8 +96,8 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - w_states: List[List[IState]], - ) -> Iterable[SynAlgo]: + states: Set[LinkedState], + ) -> List[SynAlgo]: """Main API function to retrieve all synonyms provided by a fuzzy algorithm. @@ -106,7 +106,7 @@ def get_synonyms( around the token of interest given by 'i' parameter. :param token: the token of this sequence for which synonyms are expected. - :param w_states: the states in which the algorithm currently is. + :param states: the states in which the algorithm currently is. Useful is the fuzzy algorithm needs to know the current states and the possible state transitions. :return: 0 to many synonyms (SynAlgo type). @@ -114,14 +114,6 @@ def get_synonyms( raise NotImplementedError -def get_possible_transitions(w_states: List[List[IState]]) -> Iterable[INode]: - """Return all the states (nodes) where the algorithm can go.""" - for states in w_states: - for state in states: - for child_nodes in state.node.get_child_nodes(): - yield child_nodes - - class ContextFreeAlgo(FuzzyAlgo[TokenT], ABC): """A :class:`~iamsystem.FuzzyAlgo` that doesn't take into account context, only the current token.""" @@ -133,11 +125,12 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - w_states: List[List[IState]], - ) -> Iterable[SynAlgo]: + states: Set[LinkedState], + ) -> List[SynAlgo]: """Delegate to get_syns_of_token.""" - for syn in self.get_syns_of_token(token=token): - yield syn, self.name + return [ + (syn, self.name) for syn in self.get_syns_of_token(token=token) + ] @abstractmethod def get_syns_of_token(self, token: TokenT) -> Iterable[SynType]: @@ -194,11 +187,9 @@ def __init__( """ super().__init__(name) self._min_nb_char = min_nb_char - self._tokens2ignore: IWords2ignore - if words2ignore is None: - self._tokens2ignore = SimpleWords2ignore() - else: - self._tokens2ignore = words2ignore + self._tokens2ignore: IWords2ignore = ( + words2ignore or SimpleWords2ignore() + ) @property def min_nb_char(self): diff --git a/src/iamsystem/fuzzy/cache.py b/src/iamsystem/fuzzy/cache.py index 3c72b2e..fd5f025 100644 --- a/src/iamsystem/fuzzy/cache.py +++ b/src/iamsystem/fuzzy/cache.py @@ -6,11 +6,12 @@ from typing import Iterable from typing import List from typing import Sequence +from typing import Set from iamsystem.fuzzy.api import FuzzyAlgo from iamsystem.fuzzy.api import INormLabelAlgo from iamsystem.fuzzy.api import SynAlgo -from iamsystem.matcher.util import IState +from iamsystem.matcher.util import LinkedState from iamsystem.tokenization.api import IToken from iamsystem.tokenization.api import TokenT @@ -47,7 +48,7 @@ def get_synonyms( self, tokens: Sequence[IToken], token: TokenT, - w_states: List[List[IState]], + states: Set[LinkedState], ) -> List[SynAlgo]: """Implements superclass abstract method.""" word = token.norm_label diff --git a/src/iamsystem/matcher/annotation.py b/src/iamsystem/matcher/annotation.py index e98a3fd..4596e98 100644 --- a/src/iamsystem/matcher/annotation.py +++ b/src/iamsystem/matcher/annotation.py @@ -13,8 +13,7 @@ from iamsystem.keywords.api import IKeyword from iamsystem.matcher.api import IAnnotation from iamsystem.matcher.api import IBratFormatter -from iamsystem.matcher.util import TransitionState -from iamsystem.tokenization.api import IToken +from iamsystem.matcher.util import LinkedState from iamsystem.tokenization.api import TokenT from iamsystem.tokenization.span import Span from iamsystem.tokenization.span import is_shorter_span_of @@ -72,7 +71,7 @@ def label(self): return self.tokens_label @property - def stop_tokens(self) -> List[IToken]: + def stop_tokens(self) -> List[TokenT]: """The list of stopwords tokens inside the annotation detected by the Matcher stopwords instance.""" # Note that _stop_tokens are stopwords of the document. The reason to @@ -202,6 +201,7 @@ def rm_nested_annots(annots: List[Annotation], keep_ancestors=False): # executed. ancest_indices = set() short_indices = set() + # count = 0 for i, annot in enumerate(annots): for _y, other in enumerate(annots[(i + 1) :]): # noqa y = _y + i + 1 # y is the indice of other in annots list. @@ -214,6 +214,8 @@ def rm_nested_annots(annots: List[Annotation], keep_ancestors=False): ancest_indices.add(i) if is_shorter_span_of(other, annot): short_indices.add(y) + # count += 1 + # print(f"count:{count}") if keep_ancestors: indices_2_remove = set( [i for i in short_indices if i not in ancest_indices] @@ -228,14 +230,14 @@ def rm_nested_annots(annots: List[Annotation], keep_ancestors=False): def create_annot( - last_el: TransitionState, stop_tokens: List[TokenT] + last_el: LinkedState, stop_tokens: List[TokenT] ) -> Annotation: """last_el contains a sequence of tokens in text and a final state (a matcher keyword).""" if not last_el.node.is_a_final_state(): raise ValueError("Last element is not a final state.") + last_state = last_el.node trans_states = linkedlist_to_list(last_el) - last_state = trans_states[-1].node # order by token indice. Note that last node is not last anymore. trans_states.sort(key=lambda x: x.token.i) tokens: List[TokenT] = [t.token for t in trans_states] @@ -253,16 +255,16 @@ def create_annot( return annot -def linkedlist_to_list(last_el: TransitionState) -> List[TransitionState]: +def linkedlist_to_list(last_el: LinkedState) -> List[LinkedState]: """Convert a linked list to a list.""" - trans_states: List[TransitionState] = [last_el] + states: List[LinkedState] = [last_el] parent = last_el.parent - # it stops when reaching the initial state. - while isinstance(parent, TransitionState): - trans_states.append(parent) + # it stops when reaching the initial state which parent is None. + while parent.parent is not None: + states.append(parent) parent = parent.parent - trans_states.reverse() - return trans_states + states.reverse() + return states def replace_annots( diff --git a/src/iamsystem/matcher/matcher.py b/src/iamsystem/matcher/matcher.py index 2ae7eec..fc0cefd 100644 --- a/src/iamsystem/matcher/matcher.py +++ b/src/iamsystem/matcher/matcher.py @@ -38,9 +38,8 @@ from iamsystem.matcher.annotation import rm_nested_annots from iamsystem.matcher.annotation import sort_annot from iamsystem.matcher.api import IMatcher -from iamsystem.matcher.util import IState -from iamsystem.matcher.util import StartState -from iamsystem.matcher.util import TransitionState +from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import create_start_state from iamsystem.stopwords.api import ISimpleStopwords from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.api import IStoreStopwords @@ -262,19 +261,19 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - w_states: List[List[IState]], - ) -> Iterable[SynAlgos]: + states: Set[LinkedState], + ) -> List[SynAlgos]: """Get synonyms of a token with configured fuzzy algorithms. :param tokens: document's tokens. :param token: the token for which synonyms are expected. - :param w_states: algorithm's states. + :param states: algorithm's states. :return: tuples of synonyms and fuzzy algorithm's names. """ syns_collector = defaultdict(list) for algo in self.fuzzy_algos: for syn, algo_name in algo.get_synonyms( - tokens=tokens, token=token, w_states=w_states + tokens=tokens, token=token, states=states ): syns_collector[syn].append(algo_name) synonyms: List[SynAlgos] = list(syns_collector.items()) @@ -320,10 +319,10 @@ def build( remove_nested_annots=True, string_distance_ignored_w: Optional[Iterable[str]] = None, abbreviations: Optional[Iterable[Tuple[str, str]]] = None, - spellwise: Optional[List[Dict[Any]]] = None, - simstring: Optional[List[Dict[Any]]] = None, - normalizers: Optional[List[Dict[Any]]] = None, - fuzzy_regex: Optional[List[Dict[Any]]] = None, + spellwise: Optional[List[Dict[Any, Any]]] = None, + simstring: Optional[List[Dict[Any, Any]]] = None, + normalizers: Optional[List[Dict[Any, Any]]] = None, + fuzzy_regex: Optional[List[Dict[Any, Any]]] = None, ) -> Matcher[TokenT]: """ Create an IAMsystem matcher to annotate documents. @@ -371,7 +370,7 @@ def build( ) # Start building and configuring the matcher - matcher = Matcher(tokenizer=tokenizer) + matcher: Matcher[TokenT] = Matcher(tokenizer=tokenizer) # Configure stopwords if isinstance(stopwords, Iterable): @@ -460,9 +459,9 @@ def add_algo_in_cache(algo=INormLabelAlgo): # don't override user's 'words2ignore': if "words2ignore" not in params: params["words2ignore"] = words2ignore - spellwise = SpellWiseWrapper(**params) - spellwise.add_words(words=matcher.get_keywords_unigrams()) - add_algo_in_cache(algo=spellwise) + algo = SpellWiseWrapper(**params) + algo.add_words(words=matcher.get_keywords_unigrams()) + add_algo_in_cache(algo=algo) # Parameterize simstring if simstring is not None: @@ -498,48 +497,70 @@ def detect( :return: A list of :class:`~iamsystem.Annotation`. """ annots: List[Annotation] = [] - # +1 to insert the start_state. - w_states: List[List[IState]] = [[]] * (w + 1) - start_state = StartState(node=initial_state) - # [w] element stores only the start_state. This element is not replaced. - w_states[w] = [start_state] - # different from i for a stopword-independent window size. + # states stores linkedstate instance that keeps track of a tree path + # and document's tokens that matched. + states: Set[LinkedState] = set() + start_state = create_start_state(initial_state=initial_state) + states.add(start_state) + # count_not_stopword allows a stopword-independent window size. count_not_stopword = 0 stop_tokens: List[TokenT] = [] + new_states: List[LinkedState] = [] + # states2remove store states that will be out-of-reach + # at next iteration. + states2remove: List[LinkedState] = [] for i, token in enumerate(tokens): if stopwords.is_token_a_stopword(token): stop_tokens.append(token) continue + # w_bucket stores when a state will be out-of-reach given window size + # 'count_not_stopword % w' has range [0 ; w-1] + w_bucket = count_not_stopword % w + new_states.clear() + states2remove.clear() count_not_stopword += 1 - syns_algos: Iterable[SynAlgos] = syns_provider.get_synonyms( - tokens=tokens, token=token, w_states=w_states + # syns: 1 to many synonyms depending on fuzzy_algos configuration. + syns_algos: List[SynAlgos] = syns_provider.get_synonyms( + tokens=tokens, token=token, states=states ) - # stores matches between document's tokens and keywords'tokens. - tokens_states: List[TransitionState] = [] - # 1 to many synonyms depending on fuzzy_algos configuration. - for syn, algos in syns_algos: + for state in states: + if state.w_bucket == w_bucket: + states2remove.append(state) # 0 to many states for [0] to [w-1] ; [w] only the start state. - for states in w_states: - for state in states: - new_state = state.node.jump_to_node(syn) - # when no path is found, EMPTY_NODE is returned. - if new_state is EMPTY_NODE: - continue - token_state = TransitionState( - parent=state, node=new_state, token=token, algos=algos + for syn, algos in syns_algos: + node = state.node.jump_to_node(syn) + # when no path is found, EMPTY_NODE is returned. + if node is EMPTY_NODE: + continue + new_state = LinkedState( + parent=state, + node=node, + token=token, + algos=algos, + w_bucket=w_bucket, + ) + new_states.append(new_state) + # Why 'new_state not in states': + # if node_num is already in the states set, + # it means an annotation was already created for this state. + # For example 'cancer cancer', if an annotation was created + # for the first 'cancer' then we don't want to create + # a new one for the second 'cancer'. + if node.is_a_final_state() and new_state not in states: + annot = create_annot( + last_el=new_state, stop_tokens=stop_tokens ) - tokens_states.append(token_state) - if new_state.is_a_final_state(): - annot = create_annot( - last_el=token_state, stop_tokens=stop_tokens - ) - annots.append(annot) - # function 'count_not_stopword % w' has range [0 ; w-1] - w_states[count_not_stopword % w].clear() - w_states[count_not_stopword % w] = tokens_states - # Mypy: Incompatible types in assignment (expression has type - # "List[TokenState[Any]]", target has type "List[State]") - # but TokenState is a sublcass of State. + annots.append(annot) + # Prepare next iteration: first loop remove out-of-reach states. + # Second iteration add new states. + for state in states2remove: + states.remove(state) + for state in new_states: + # this condition happens in the 'cancer cancer' example. + # the effect is replacing a previous token by a new one. + if state in states: + states.remove(state) + states.add(state) sort_annot(annots) # mutate the list like annots.sort() return annots diff --git a/src/iamsystem/matcher/util.py b/src/iamsystem/matcher/util.py index 824fa88..01edff3 100644 --- a/src/iamsystem/matcher/util.py +++ b/src/iamsystem/matcher/util.py @@ -4,37 +4,25 @@ from typing import Generic from typing import List - -from typing_extensions import Protocol +from typing import Optional from iamsystem.tokenization.api import TokenT +from iamsystem.tokenization.token import Token from iamsystem.tree.nodes import INode -class IState(Protocol): - """A class that keeps track of a state, stored as a node in a tree.""" - - node: INode - - -class StartState(IState): - """A class to store the starting/initial state.""" - - def __init__(self, node: INode): - """Stores the initial_state. - - :param node: the initial state/node, i.e. a root_node. - """ - self.node = node - - -class TransitionState(IState, Generic[TokenT]): +class LinkedState(Generic[TokenT]): """Keep track of the sequence of tokens in a document that matched the sequence of tokens of a keyword. This object is a linked list, - the first element the start_state, others are transition_state.""" + the first element the create_start_state, others are transition_state.""" def __init__( - self, parent: IState, node: INode, token: TokenT, algos: List[str] + self, + parent: Optional[LinkedState], + node: INode, + token: TokenT, + algos: List[str], + w_bucket: int, ): """ @@ -44,8 +32,43 @@ def __init__( :class:`~iamsystem.IToken` protocol. :param algos: the algorthim(s) that matched this state's token (from a keyword) to the document's token. + :param w_bucket: the window bucket used to test if this instance + becomes out-of-reach and should be remove. """ self.node = node self.token = token self.parent = parent self.algos = algos + self.w_bucket = w_bucket + + def __eq__(self, other): + """Two nodes are equal if they have the same number.""" + # I removed these verifications to speed up the algorithm. + # if self is other: + # return True + # if isinstance(other, int): + # return self.node.node_num == other + # if not isinstance(other, LinkedState): + # return False + # other_state: LinkedState = other + return self.node.node_num == other.node.node_num + + def __hash__(self): + """Uses the node number as a unique identifier.""" + return self.node.node_num + + +def create_start_state(initial_state: INode): + return LinkedState( + parent=None, + node=initial_state, + token=Token( + start=-1, + end=-1, + norm_label="START_TOKEN", + i=-1, + label="START_TOKEN", + ), + algos=[], + w_bucket=-1, + ) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 3edfa69..a3956de 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -8,7 +8,10 @@ from iamsystem.matcher.annotation import rm_nested_annots from iamsystem.matcher.annotation import sort_annot from iamsystem.matcher.matcher import Matcher +from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import create_start_state from iamsystem.tokenization.span import is_shorter_span_of +from iamsystem.tree.trie import Trie from tests.utils_detector import get_gauche_el_in_ivg @@ -158,6 +161,45 @@ def test_create_annot(self): substring = text[annot.start : annot.end] # noqa self.assertEqual("Insuffisance Ventriculaire Gauche", substring) + def test_create_start_state(self): + """When parent is none, it's the start_state""" + trie = Trie() + start_state = create_start_state( + initial_state=trie.get_initial_state() + ) + self.assertTrue(start_state.parent is None) + + def test_transition_state_equality(self): + """Two transititions states are 'equal' if they have the same + node number. This equality is important since a 'new' state needs to + override an existing state.""" + gauche_node, gauche_el = get_gauche_el_in_ivg() + trans_state_0 = LinkedState( + parent=None, + node=gauche_node, + token=self.annots[0].tokens[0], + algos=["one"], + w_bucket=0, + ) + start_state = create_start_state( + initial_state=Trie().get_initial_state() + ) + trans_state_1 = LinkedState( + parent=start_state, + node=gauche_node, + token=None, # noqa + algos=["one"], + w_bucket=0, + ) + self.assertEqual(trans_state_0, trans_state_1) + trans_set = set() + trans_set.add(trans_state_0) + trans_set.discard(trans_state_1) + trans_set.add(trans_state_1) + self.assertEqual(len(trans_set), 1) + for trans in trans_set: + self.assertTrue(trans.token is None) # trans_state_1 overrides + def test_to_dict(self): """Check attribute values.""" gauche_node, gauche_el = get_gauche_el_in_ivg() diff --git a/tests/test_detect.py b/tests/test_detect.py index d283c90..ee985e0 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -14,7 +14,7 @@ from iamsystem.matcher.annotation import rm_nested_annots from iamsystem.matcher.matcher import Matcher from iamsystem.matcher.matcher import detect -from iamsystem.matcher.util import IState +from iamsystem.matcher.util import LinkedState from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.simple import Stopwords from iamsystem.tokenization.api import IToken @@ -305,7 +305,7 @@ def get_synonyms( self, tokens: Sequence[TokenPOS], token: TokenPOS, - w_states: List[List[IState]], + states: List[List[LinkedState]], ) -> Iterable[SynAlgo]: """Returns only if POS is NOUN""" if token.pos == "NOUN": diff --git a/tests/test_matcher.py b/tests/test_matcher.py index b3095e2..b7cb138 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -215,6 +215,24 @@ def test_duplicate_states_generate_lot_of_overlaps(self): text="cancer cancer de de la la prostate prostate" ) self.assertEqual(len(annots), 1) + self.assertEqual( + str(annots[0]), + "cancer de la prostate 7 13;17 19;23 34 cancer de la prostate", + ) + + def test_duplicate_states_annotations_created(self): + """Check it creates two annotations, one for the first occurence of + 'cancer', the next one using the last occurence of 'cancer'.""" + matcher = Matcher.build( + keywords=["cancer", "cancer de la prostate"], w=10 + ) + annots = matcher.annot_text(text="cancer cancer cancer de la prostate") + self.assertEqual(len(annots), 2) + self.assertEqual(str(annots[0]), "cancer 0 6 cancer") + self.assertEqual( + str(annots[1]), + "cancer de la prostate 14 35 cancer de la prostate", + ) class AnotherFuzzyAlgo(NormLabelAlgo): diff --git a/tests/utils_detector.py b/tests/utils_detector.py index 878a1cf..c04ba06 100644 --- a/tests/utils_detector.py +++ b/tests/utils_detector.py @@ -3,8 +3,8 @@ from iamsystem.fuzzy.abbreviations import Abbreviations from iamsystem.keywords.collection import Terminology from iamsystem.keywords.keywords import Entity -from iamsystem.matcher.util import StartState -from iamsystem.matcher.util import TransitionState +from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import create_start_state from iamsystem.tokenization.token import Token from iamsystem.tokenization.tokenize import french_tokenizer from iamsystem.tree.nodes import Node @@ -30,19 +30,23 @@ def get_abbs_irc() -> Abbreviations: return abbs -def get_gauche_el_in_ivg() -> Tuple[Node, TransitionState]: +def get_gauche_el_in_ivg() -> Tuple[Node, LinkedState]: """Return a transition state.""" # root_node trie = Trie() root_node = trie.get_initial_state() - root_el = StartState(node=trie.root_node) + root_el = create_start_state(initial_state=trie.root_node) # insuffisance ins_node = Node(token="insuffisance", node_num=1, parent_node=root_node) ins_span = Token( label="Insuffisance", norm_label="insuffisance", start=0, end=12, i=0 ) - ins_el: TransitionState = TransitionState( - node=ins_node, token=ins_span, parent=root_el, algos=["exact"] + ins_el: LinkedState = LinkedState( + node=ins_node, + token=ins_span, + parent=root_el, + algos=["exact"], + w_bucket=0, ) # ventriculaire vent_node = Node(token="ventriculaire", node_num=2, parent_node=ins_node) @@ -53,16 +57,24 @@ def get_gauche_el_in_ivg() -> Tuple[Node, TransitionState]: end=26, i=1, ) - vent_el: TransitionState = TransitionState( - node=vent_node, token=vent_span, parent=ins_el, algos=["exact"] + vent_el: LinkedState = LinkedState( + node=vent_node, + token=vent_span, + parent=ins_el, + algos=["exact"], + w_bucket=0, ) # gauche gauche_node = Node(token="gauche", node_num=3, parent_node=vent_node) gauche_span = Token( label="Gauche", norm_label="gauche", start=28, end=34, i=2 ) - gauche_el: TransitionState = TransitionState( - node=gauche_node, token=gauche_span, parent=vent_el, algos=["exact"] + gauche_el: LinkedState = LinkedState( + node=gauche_node, + token=gauche_span, + parent=vent_el, + algos=["exact"], + w_bucket=0, ) return gauche_node, gauche_el From a6ab2cd7d3fa20dcfc2d5abc6b3709a8e77fa4fa Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 4 Mar 2023 17:03:27 -0300 Subject: [PATCH 05/23] #12: Define a IMatchingStrategy interface, implement multiple macthing strategies --- src/iamsystem/fuzzy/api.py | 7 +- src/iamsystem/fuzzy/cache.py | 3 +- src/iamsystem/matcher/api.py | 26 +++ src/iamsystem/matcher/matcher.py | 128 +++-------- src/iamsystem/matcher/strategy.py | 368 ++++++++++++++++++++++++++++++ src/iamsystem/matcher/util.py | 11 +- src/iamsystem/tree/nodes.py | 15 ++ tests/test_detect.py | 10 +- tests/test_matcher.py | 66 ++++++ 9 files changed, 521 insertions(+), 113 deletions(-) create mode 100644 src/iamsystem/matcher/strategy.py diff --git a/src/iamsystem/fuzzy/api.py b/src/iamsystem/fuzzy/api.py index ecdb361..19d723c 100644 --- a/src/iamsystem/fuzzy/api.py +++ b/src/iamsystem/fuzzy/api.py @@ -8,7 +8,6 @@ from typing import List from typing import Optional from typing import Sequence -from typing import Set from typing import Tuple from typing_extensions import Protocol @@ -39,7 +38,7 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - states: Set[LinkedState], + states: Iterable[LinkedState], ) -> List[SynAlgos]: """Retrieve the synonyms of a token. @@ -96,7 +95,7 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - states: Set[LinkedState], + states: Iterable[LinkedState], ) -> List[SynAlgo]: """Main API function to retrieve all synonyms provided by a fuzzy algorithm. @@ -125,7 +124,7 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - states: Set[LinkedState], + states: Iterable[LinkedState], ) -> List[SynAlgo]: """Delegate to get_syns_of_token.""" return [ diff --git a/src/iamsystem/fuzzy/cache.py b/src/iamsystem/fuzzy/cache.py index fd5f025..3afc9cf 100644 --- a/src/iamsystem/fuzzy/cache.py +++ b/src/iamsystem/fuzzy/cache.py @@ -6,7 +6,6 @@ from typing import Iterable from typing import List from typing import Sequence -from typing import Set from iamsystem.fuzzy.api import FuzzyAlgo from iamsystem.fuzzy.api import INormLabelAlgo @@ -48,7 +47,7 @@ def get_synonyms( self, tokens: Sequence[IToken], token: TokenT, - states: Set[LinkedState], + states: Iterable[LinkedState], ) -> List[SynAlgo]: """Implements superclass abstract method.""" word = token.norm_label diff --git a/src/iamsystem/matcher/api.py b/src/iamsystem/matcher/api.py index e1db5a0..9cda5b1 100644 --- a/src/iamsystem/matcher/api.py +++ b/src/iamsystem/matcher/api.py @@ -14,6 +14,7 @@ from iamsystem.tokenization.api import ITokenizer from iamsystem.tokenization.api import TokenT from iamsystem.tree.api import IInitialState +from iamsystem.tree.nodes import INode @runtime_checkable @@ -82,3 +83,28 @@ def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: """Return text (document substring) and annotation's offsets in the Brat format""" raise NotImplementedError + + +@runtime_checkable +class IMatchingStrategy(Protocol): + """Declare what a matching strategy must implement.""" + + def detect( + self, + tokens: Sequence[TokenT], + w: int, + initial_state: INode, + syns_provider: ISynsProvider, + stopwords: IStopwords, + ) -> List[IAnnotation[TokenT]]: + """Main internal function that implements iamsystem's algorithm. + + :param tokens: a sequence of :class:`~iamsystem.IToken`. + :param w: window, how many previous tokens can the algorithm look at. + :param initial_state: a node/state in the trie, i.e. the root node. + :param syns_provider: a class that provides synonyms for each token. + :param stopwords: an instance of :class:`~iamsystem.IStopwords` + that checks if a token is a stopword. + :return: A list of :class:`~iamsystem.Annotation`. + """ + raise NotImplementedError diff --git a/src/iamsystem/matcher/matcher.py b/src/iamsystem/matcher/matcher.py index fc0cefd..d7c680a 100644 --- a/src/iamsystem/matcher/matcher.py +++ b/src/iamsystem/matcher/matcher.py @@ -19,7 +19,6 @@ from iamsystem.fuzzy.abbreviations import Abbreviations from iamsystem.fuzzy.api import FuzzyAlgo from iamsystem.fuzzy.api import INormLabelAlgo -from iamsystem.fuzzy.api import ISynsProvider from iamsystem.fuzzy.api import SynAlgos from iamsystem.fuzzy.cache import CacheFuzzyAlgos from iamsystem.fuzzy.exact import ExactMatch @@ -34,12 +33,13 @@ from iamsystem.keywords.keywords import Keyword from iamsystem.keywords.util import get_unigrams from iamsystem.matcher.annotation import Annotation -from iamsystem.matcher.annotation import create_annot from iamsystem.matcher.annotation import rm_nested_annots -from iamsystem.matcher.annotation import sort_annot from iamsystem.matcher.api import IMatcher +from iamsystem.matcher.api import IMatchingStrategy +from iamsystem.matcher.strategy import EMatchingStrategy +from iamsystem.matcher.strategy import WindowMatching +from iamsystem.matcher.strategy import buildMatchingStrategy from iamsystem.matcher.util import LinkedState -from iamsystem.matcher.util import create_start_state from iamsystem.stopwords.api import ISimpleStopwords from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.api import IStoreStopwords @@ -50,7 +50,6 @@ from iamsystem.tokenization.api import TokenT from iamsystem.tokenization.tokenize import french_tokenizer from iamsystem.tokenization.tokenize import tokenize_and_order_decorator -from iamsystem.tree.nodes import EMPTY_NODE from iamsystem.tree.nodes import INode from iamsystem.tree.trie import Trie @@ -80,10 +79,22 @@ def __init__( self._trie: Trie = Trie() self._termino: IStoreKeywords = Terminology() self._remove_nested_annots = True - if stopwords is not None: - self._stopwords = stopwords - else: - self._stopwords = Stopwords() + self._stopwords = stopwords or Stopwords() + self._strategy: IMatchingStrategy = WindowMatching() + + @property + def strategy(self) -> IMatchingStrategy[TokenT]: + """Return the matching strategy.""" + return self._strategy + + @strategy.setter + def strategy(self, strategy: IMatchingStrategy) -> None: + """Change the matching strategy. + + :param strategy: an IAMsystem matching strategy. + :return: None. + """ + self._strategy = strategy @property def stopwords(self) -> IStopwords[TokenT]: @@ -261,7 +272,7 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - states: Set[LinkedState], + states: Iterable[LinkedState], ) -> List[SynAlgos]: """Get synonyms of a token with configured fuzzy algorithms. @@ -296,7 +307,7 @@ def annot_tokens( :param tokens: an ordered or unordered sequence of tokens. :return: a list of :class:`~iamsystem.Annotation`. """ - annots = detect( + annots = self._strategy.detect( tokens=tokens, w=self.w, initial_state=self.get_initial_state(), @@ -317,6 +328,7 @@ def build( order_tokens=False, negative=False, remove_nested_annots=True, + strategy: Union[str, EMatchingStrategy] = EMatchingStrategy.WINDOW, string_distance_ignored_w: Optional[Iterable[str]] = None, abbreviations: Optional[Iterable[Tuple[str, str]]] = None, spellwise: Optional[List[Dict[Any, Any]]] = None, @@ -344,6 +356,9 @@ def build( removed from keywords' tokens and so still be stopwords. :param remove_nested_annots: if two annotations overlap, remove the shorter one. Default to True. + :param strategy: an IAMsystem matching strategy responsible for + searching keywords in document. + Default to :class:`~iamsystem.WindowMatching`. :param string_distance_ignored_w: words ignored by string distance algorithms to avoid false positives matched. :param abbreviations: an iterable of tuples (short_form, long_form). @@ -381,6 +396,7 @@ def build( # Configure annot_text function matcher.w = w matcher.remove_nested_annots = remove_nested_annots + matcher.strategy = buildMatchingStrategy(strategy=strategy) # Add the keywords matcher.add_keywords(keywords=keywords) @@ -474,93 +490,5 @@ def add_algo_in_cache(algo=INormLabelAlgo): **params, ) add_algo_in_cache(algo=ss_algo) - + # matcher.strategy = LargeWindowDetector(matcher.get_initial_state()) return matcher - - -def detect( - tokens: Sequence[TokenT], - w: int, - initial_state: INode, - syns_provider: ISynsProvider, - stopwords: IStopwords, -) -> List[Annotation[TokenT]]: - """Main internal function that implements iamsystem's algorithm. - Algorithm formalized in https://ceur-ws.org/Vol-3202/livingner-paper11.pdf - - :param tokens: a sequence of :class:`~iamsystem.IToken`. - :param w: window, how many previous tokens can the algorithm look at. - :param initial_state: a node/state in the trie, i.e. the root node. - :param syns_provider: a class that provides synonyms for each token. - :param stopwords: an instance of :class:`~iamsystem.IStopwords` - that checks if a token is a stopword. - :return: A list of :class:`~iamsystem.Annotation`. - """ - annots: List[Annotation] = [] - # states stores linkedstate instance that keeps track of a tree path - # and document's tokens that matched. - states: Set[LinkedState] = set() - start_state = create_start_state(initial_state=initial_state) - states.add(start_state) - # count_not_stopword allows a stopword-independent window size. - count_not_stopword = 0 - stop_tokens: List[TokenT] = [] - new_states: List[LinkedState] = [] - # states2remove store states that will be out-of-reach - # at next iteration. - states2remove: List[LinkedState] = [] - for i, token in enumerate(tokens): - if stopwords.is_token_a_stopword(token): - stop_tokens.append(token) - continue - # w_bucket stores when a state will be out-of-reach given window size - # 'count_not_stopword % w' has range [0 ; w-1] - w_bucket = count_not_stopword % w - new_states.clear() - states2remove.clear() - count_not_stopword += 1 - # syns: 1 to many synonyms depending on fuzzy_algos configuration. - syns_algos: List[SynAlgos] = syns_provider.get_synonyms( - tokens=tokens, token=token, states=states - ) - - for state in states: - if state.w_bucket == w_bucket: - states2remove.append(state) - # 0 to many states for [0] to [w-1] ; [w] only the start state. - for syn, algos in syns_algos: - node = state.node.jump_to_node(syn) - # when no path is found, EMPTY_NODE is returned. - if node is EMPTY_NODE: - continue - new_state = LinkedState( - parent=state, - node=node, - token=token, - algos=algos, - w_bucket=w_bucket, - ) - new_states.append(new_state) - # Why 'new_state not in states': - # if node_num is already in the states set, - # it means an annotation was already created for this state. - # For example 'cancer cancer', if an annotation was created - # for the first 'cancer' then we don't want to create - # a new one for the second 'cancer'. - if node.is_a_final_state() and new_state not in states: - annot = create_annot( - last_el=new_state, stop_tokens=stop_tokens - ) - annots.append(annot) - # Prepare next iteration: first loop remove out-of-reach states. - # Second iteration add new states. - for state in states2remove: - states.remove(state) - for state in new_states: - # this condition happens in the 'cancer cancer' example. - # the effect is replacing a previous token by a new one. - if state in states: - states.remove(state) - states.add(state) - sort_annot(annots) # mutate the list like annots.sort() - return annots diff --git a/src/iamsystem/matcher/strategy.py b/src/iamsystem/matcher/strategy.py new file mode 100644 index 0000000..d8cf385 --- /dev/null +++ b/src/iamsystem/matcher/strategy.py @@ -0,0 +1,368 @@ +""" IAMsystem matching strategies.""" +from collections import defaultdict +from enum import Enum +from typing import Dict +from typing import List +from typing import Sequence +from typing import Set +from typing import Union + +from iamsystem.fuzzy.api import ISynsProvider +from iamsystem.fuzzy.api import SynAlgos +from iamsystem.matcher.annotation import Annotation +from iamsystem.matcher.annotation import create_annot +from iamsystem.matcher.annotation import sort_annot +from iamsystem.matcher.api import IAnnotation +from iamsystem.matcher.api import IMatchingStrategy +from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import create_start_state +from iamsystem.stopwords.api import IStopwords +from iamsystem.tokenization.api import TokenT +from iamsystem.tree.nodes import EMPTY_NODE +from iamsystem.tree.nodes import INode + + +class EMatchingStrategy(Enum): + """Enumeration of matching strategies.""" + + WINDOW = "WINDOW" + LARGE_WINDOW = "LARGE_WINDOW" + NO_OVERLAP = "NO_OVERLAP" + + +def buildMatchingStrategy( + strategy: Union[str, EMatchingStrategy] +) -> IMatchingStrategy: + """Matching strategy factory. + + :param strategy: the name of the value strategy. + :return: a matching strategy. + """ + if isinstance(strategy, str): + strategy = EMatchingStrategy[strategy.upper()] + if strategy == EMatchingStrategy.WINDOW: + return WindowMatching() + if strategy == EMatchingStrategy.LARGE_WINDOW: + return LargeWindowMatching() + if strategy == EMatchingStrategy.NO_OVERLAP: + return NoOverlapMatching() + + +class WindowMatching(IMatchingStrategy): + """Default matching strategy. + It keeps track of all states within a window range and can be produce + overlapping/nested annotations. + If you want to use a large window with a large dictionary, it is + recommended to use 'LargeWindowMatching' instead. + """ + + def detect( + self, + tokens: Sequence[TokenT], + w: int, + initial_state: INode, + syns_provider: ISynsProvider, + stopwords: IStopwords, + ) -> List[IAnnotation[TokenT]]: + """Overrides.""" + annots: List[Annotation] = [] + # states stores linkedstate instance that keeps track of a tree path + # and document's tokens that matched. + states: Set[LinkedState] = set() + start_state = create_start_state(initial_state=initial_state) + states.add(start_state) + # count_not_stopword allows a stopword-independent window size. + count_not_stopword = 0 + stop_tokens: List[TokenT] = [] + new_states: List[LinkedState] = [] + # states2remove store states that will be out-of-reach + # at next iteration. + states2remove: List[LinkedState] = [] + for i, token in enumerate(tokens): + if stopwords.is_token_a_stopword(token): + stop_tokens.append(token) + continue + # w_bucket stores when a state will be out-of-reach + # 'count_not_stopword % w' has range [0 ; w-1] + w_bucket = count_not_stopword % w + new_states.clear() + states2remove.clear() + count_not_stopword += 1 + # syns: 1 to many synonyms depending on fuzzy_algos configuration. + syns_algos: List[SynAlgos] = syns_provider.get_synonyms( + tokens=tokens, token=token, states=states + ) + + for state in states: + if state.w_bucket == w_bucket: + states2remove.append(state) + # 0 to many states for [0] to [w-1] ; [w] only the start state. + for syn, algos in syns_algos: + node = state.node.jump_to_node(syn) + # when no path is found, EMPTY_NODE is returned. + if node is EMPTY_NODE: + continue + new_state = LinkedState( + parent=state, + node=node, + token=token, + algos=algos, + w_bucket=w_bucket, + ) + new_states.append(new_state) + # Why 'new_state not in states': + # if node_num is already in the states set,it means + # an annotation was already created for this state. + # For example 'cancer cancer', if an annotation was created + # for the first 'cancer' then we don't want to create + # a new one for the second 'cancer'. + if node.is_a_final_state() and new_state not in states: + annot = create_annot( + last_el=new_state, stop_tokens=stop_tokens + ) + annots.append(annot) + # Prepare next iteration: first loop remove out-of-reach states. + # Second iteration add new states. + for state in states2remove: + states.remove(state) + for state in new_states: + # this condition happens in the 'cancer cancer' example. + # the effect is replacing a previous token by a new one. + if state in states: + states.remove(state) + states.add(state) + sort_annot(annots) # mutate the list like annots.sort() + return annots + + +class LargeWindowMatching(IMatchingStrategy): + """A large window strategy suited for a large window (ex: w=1000) and + a large dictionary. This strategy is faster than the Window strategy if + the dictionary is large, otherwise it's slower. It trades space for + time complexity (space memory increases but matching speed increases). + """ + + def __init__(self): + self.states = {} + self.avaible_trans = {} + self.is_initialized = False + + def detect( + self, + tokens: Sequence[TokenT], + w: int, + initial_state: INode, + syns_provider: ISynsProvider, + stopwords: IStopwords, + ) -> List[IAnnotation[TokenT]]: + """Overrides.""" + if not self.is_initialized: + self._initialize(initial_state=initial_state) + self.is_initialized = True + annots: List[Annotation] = [] + states: Dict[int, LinkedState] = self.states.copy() + # avaible_trans stores which state have a transition to a synonym. + avaible_trans: Dict[str, Set[int]] = self.avaible_trans.copy() + count_not_stopword = 0 + stop_tokens: List[TokenT] = [] + new_states: Set[LinkedState] = set() + emptylist = [] + for i, token in enumerate(tokens): + if stopwords.is_token_a_stopword(token): + stop_tokens.append(token) + continue + new_states.clear() + count_not_stopword += 1 + # syns: 1 to many synonyms depending on fuzzy_algos configuration. + syns_algos: List[SynAlgos] = syns_provider.get_synonyms( + tokens=tokens, token=token, states=iter(states.values()) + ) + for syn, algos in syns_algos: + states_id = avaible_trans.get(syn[0], emptylist) + for state_id in states_id.copy(): + state: LinkedState = states.get(state_id, None) + # case a state was obsolete and removed: + if state is None: + states_id.remove(state_id) + continue + # case a state is obsolete and removed here: + if state.is_obsolete( + count_stop_word=count_not_stopword, w=w + ): + del states[state_id] + states_id.remove(state_id) + continue + node = state.node.jump_to_node(syn) + # when no path is found, EMPTY_NODE is returned. + if node is EMPTY_NODE: + # I could raise a valueError here since it should be + # impossible: all the states have a transition to + # the current synonym. + continue + new_state = LinkedState( + parent=state, + node=node, + token=token, + algos=algos, + w_bucket=count_not_stopword, + ) + new_states.add(new_state) + for state in new_states: + # create an annotation if: + # 1) node is a final state + # 2) an annotation wasn't created yet for this state: + # 2.1 there is no previous 'none-obsolete state'. + if state.node.is_a_final_state(): + old_state = states.get(state.id, None) + if old_state is None or old_state.is_obsolete( + count_stop_word=count_not_stopword, w=w + ): + annot = create_annot( + last_el=state, stop_tokens=stop_tokens + ) + annots.append(annot) + for nexttoken in state.node.get_child_tokens(): + avaible_trans[nexttoken].add(state.id) + states[state.id] = state + sort_annot(annots) # mutate the list like annots.sort() + return annots + + def _initialize(self, initial_state: INode) -> None: + """Initialize hashtable to avoid repeating this operation multiple + times. + + :param initial_state: the initial state (eg. root node). + :return: None. + """ + self.states: Dict[int, LinkedState] = {} + self.avaible_trans: Dict[str, Set[int]] = defaultdict(set) + start_state = create_start_state(initial_state=initial_state) + self.states[start_state.id] = start_state + for token in start_state.node.get_child_tokens(): + self.avaible_trans[token].add(start_state.id) + + +class NoOverlapMatching(IMatchingStrategy): + """The old matching strategy that was in used till 2022. + The 'w' parameter has no effect. + It annotates the longest path and outputs no overlapping annotation + except in case of ambiguity. It's the fastest strategy. + Algorithm formalized in https://ceur-ws.org/Vol-3202/livingner-paper11.pdf # noqa + """ + + def detect( + self, + tokens: Sequence[TokenT], + w: int, + initial_state: INode, + syns_provider: ISynsProvider, + stopwords: IStopwords, + ) -> List[IAnnotation[TokenT]]: + """Overrides. + Note that w parameter is ignored and so has no effect.""" + annots: List[Annotation] = [] + # states stores linkedstate instance that keeps track of a tree path + # and document's tokens that matched. + states: Set[LinkedState] = set() + start_state = create_start_state(initial_state=initial_state) + states.add(start_state) + stop_tokens: List[TokenT] = [] + # i stores the position of the current token. + i = 0 + # started_at is used for back-tracking, it stores the initial 'i' + # from which the initial state started. + started_at = 0 + while i < len(tokens): + token = tokens[i] + if stopwords.is_token_a_stopword(token): + stop_tokens.append(token) + i += 1 + started_at += 1 + continue + new_states: Set[LinkedState] = set() + # syns: 1 to many synonyms depending on fuzzy_algos configuration. + syns_algos: List[SynAlgos] = syns_provider.get_synonyms( + tokens=tokens, token=token, states=states + ) + for state in states: + for syn, algos in syns_algos: + node = state.node.jump_to_node(syn) + # when no path is found, EMPTY_NODE is returned. + if node is EMPTY_NODE: + continue + new_state = LinkedState( + parent=state, + node=node, + token=token, + algos=algos, + w_bucket=-1, + ) + new_states.add(new_state) + # Case the algorithm is exploring a path: + if len(new_states) != 0: + states = new_states + i += 1 + # don't 'started_at += 1' to allow backtracking later. + # Case the algorithm has finished exploring a path: + else: + # the algorithm has gone nowhere from initial state: + if len(states) == 1 and start_state in states: + i += 1 + started_at += 1 + continue + # the algorithm has gone somewhere. Save annotations and + # restart at last annotation ith token + 1 (no overlap). + last_i = self._add_annots( + annots=annots, + states=states, + started_at=started_at, + stop_tokens=stop_tokens, + ) + i = last_i + 1 + started_at = started_at + 1 + states = set() + states.add(start_state) + # All tokens have been seen. Create last annotations if any states: + self._add_annots( + annots=annots, + states=states, + started_at=started_at, + stop_tokens=stop_tokens, + ) + sort_annot(annots) # mutate the list like annots.sort() + return annots + + @staticmethod + def _add_annots( + annots: List[Annotation], + states: Set[LinkedState], + started_at: int, + stop_tokens: List[TokenT], + ) -> int: + """Create annotations and mutate annots list. + + :param annots: the list of annotations. + :param states: the current algorithm's states. + :param started_at: the 'i' token at whcih the algorithm started a + search. + :param stop_tokens: stopwords + :return: the last annotation 'i' value or started_at if no annotation + generated. + """ + last_annot_i = -1 + for state in states: + current_state = state + # back track till the first state that is a final state + # ex: 'cancer de la', backtrack to 'cancer'. + while ( + not current_state.node.is_a_final_state() + and current_state.parent is not None + ): + current_state = current_state.parent + if current_state.node.is_a_final_state(): + annot = create_annot( + last_el=current_state, stop_tokens=stop_tokens + ) + last_annot_i = max(annot.end_i, last_annot_i) + annots.append(annot) + return max(started_at, last_annot_i) diff --git a/src/iamsystem/matcher/util.py b/src/iamsystem/matcher/util.py index 01edff3..0366e1a 100644 --- a/src/iamsystem/matcher/util.py +++ b/src/iamsystem/matcher/util.py @@ -40,6 +40,13 @@ def __init__( self.parent = parent self.algos = algos self.w_bucket = w_bucket + self.id = node.node_num + + def is_obsolete(self, count_stop_word: int, w) -> bool: + distance_2_current_token = count_stop_word - self.w_bucket + if (w - distance_2_current_token < 0) and self.parent is not None: + return True + return False def __eq__(self, other): """Two nodes are equal if they have the same number.""" @@ -51,11 +58,11 @@ def __eq__(self, other): # if not isinstance(other, LinkedState): # return False # other_state: LinkedState = other - return self.node.node_num == other.node.node_num + return self.id == other.id def __hash__(self): """Uses the node number as a unique identifier.""" - return self.node.node_num + return self.id def create_start_state(initial_state: INode): diff --git a/src/iamsystem/tree/nodes.py b/src/iamsystem/tree/nodes.py index 7816f42..65a4a60 100644 --- a/src/iamsystem/tree/nodes.py +++ b/src/iamsystem/tree/nodes.py @@ -78,6 +78,11 @@ def get_token(self) -> str: """the (normalized) token stored by this node.""" raise NotImplementedError + @abstractmethod + def get_child_tokens(self) -> Iterable[str]: + """Return the children token.""" + raise NotImplementedError + class EmptyNode(INode): """An 'exit' node to go when no transition is found.""" @@ -122,6 +127,10 @@ def get_token(self) -> str: """This method shouldn't be called.""" raise NotImplementedError + def get_child_tokens(self) -> Iterable[str]: + """Return the children token.""" + raise NotImplementedError + EMPTY_NODE = EmptyNode() @@ -176,6 +185,7 @@ def jump_to_node(self, str_seq: Sequence[str]) -> INode: def add_child_node(self, node: INode) -> None: """Add a node that stores the next keyword's token.""" token = node.get_token() + self.childNodes[token] = node def is_a_final_state(self) -> bool: @@ -212,6 +222,11 @@ def get_token(self) -> str: """Return the token associated to this node.""" return self.token + def get_child_tokens(self) -> Iterable[str]: + """Return the childs' tokens.""" + for token in self.childNodes.keys(): + yield token + def __eq__(self, other): """Two nodes are equal if they have the same number.""" if self is other: diff --git a/tests/test_detect.py b/tests/test_detect.py index ee985e0..b4f42b7 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -13,7 +13,7 @@ from iamsystem.fuzzy.spellwise import SpellWiseWrapper from iamsystem.matcher.annotation import rm_nested_annots from iamsystem.matcher.matcher import Matcher -from iamsystem.matcher.matcher import detect +from iamsystem.matcher.strategy import WindowMatching from iamsystem.matcher.util import LinkedState from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.simple import Stopwords @@ -36,7 +36,7 @@ def build_detect( def _detect(tokens: Sequence[TokenT], w: int): """Return a custom detect function.""" - return detect( + return WindowMatching().detect( tokens=tokens, w=w, initial_state=trie.root_node, @@ -207,7 +207,7 @@ def test_token_type(self): i=1, ) tokens = [token_ins, token_card] - fuzzy_lemma = FuzzyAlgoPos() + fuzzy_lemma = FuzzyAlgoPos(name="fuzzyPos") self.matcher.add_fuzzy_algo(fuzzy_lemma) annots = self.detect_ivg(tokens=tokens, w=1) ins_token: TokenPOS = cast(TokenPOS, annots[0]._tokens[0]) @@ -309,9 +309,9 @@ def get_synonyms( ) -> Iterable[SynAlgo]: """Returns only if POS is NOUN""" if token.pos == "NOUN": - yield self.word_to_syn("insuffisance"), self.name + return [(self.word_to_syn("insuffisance"), self.name)] else: - yield tuple(), "NO_SYN" + return [] if __name__ == "__main__": diff --git a/tests/test_matcher.py b/tests/test_matcher.py index b7cb138..60c4f84 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -15,6 +15,8 @@ from iamsystem.matcher.annotation import Annotation from iamsystem.matcher.annotation import replace_annots from iamsystem.matcher.matcher import Matcher +from iamsystem.matcher.strategy import EMatchingStrategy +from iamsystem.matcher.strategy import LargeWindowMatching from iamsystem.stopwords.negative import NegativeStopwords from iamsystem.stopwords.simple import NoStopwords from iamsystem.stopwords.simple import Stopwords @@ -464,6 +466,70 @@ def test_fuzzy_regex(self): annots = matcher.annot_text(text="diabete en 2010") self.assertEqual(1, len(annots)) + def test_large_window(self): + """Test fuzzy regex works""" + text = "absence congénitale de pigmentation ou absence de mélanine." + matcher = Matcher.build(keywords=["absence congenitale", "absence de"]) + annots = matcher.annot_text(text=text) + self.assertEqual(2, len(annots)) + matcher.strategy = LargeWindowMatching() + annots = matcher.annot_text(text=text) + self.assertEqual(2, len(annots)) + + def test_none_existing_strategy(self): + """An error is raised if matching strategy doesn't exist""" + with (self.assertRaises(KeyError)): + self.matcher = Matcher.build( + keywords=["cancer", "cancer de la prostate"], + strategy="NoneExistingStrategy", + ) + + +class NoOverlapStrategyTest(unittest.TestCase): + def setUp(self) -> None: + self.matcher = Matcher.build( + keywords=["cancer", "cancer de la prostate", "prostate", "de la"], + strategy=EMatchingStrategy.NO_OVERLAP, + ) + + def test_no_overlap_strategy(self): + """No overlapping: 'de la', 'prostate' nested annotations are not + created.""" + text = "cancer de la prostate" + annots = self.matcher.annot_text(text=text) + self.assertEqual(1, len(annots)) + self.assertEqual( + str(annots[0]), "cancer de la prostate 0 21 cancer de la prostate" + ) + + def test_no_overlap_strategy_back_track(self): + """The algorithm goes to 'cancer de la' - backtrack to 'cancer' + to generate an annotation, restart at 'de' and annotate 'de la'.""" + text = "cancer de la something else prostate" + annots = self.matcher.annot_text(text=text) + self.assertEqual(3, len(annots)) + self.assertEqual(str(annots[0]), "cancer 0 6 cancer") + self.assertEqual(str(annots[1]), "de la 7 12 de la") + self.assertEqual(str(annots[2]), "prostate 28 36 prostate") + + def test_no_overlap_strategy_stopword(self): + """Test the streatgy words with stopwords""" + self.matcher = Matcher.build( + keywords=["cancer", "cancer de la prostate"], + stopwords=["de", "la"], + strategy="no_overlap", + ) + text = "cancer de la prostate" + annots = self.matcher.annot_text(text=text) + self.assertEqual(1, len(annots)) + self.assertEqual( + str(annots[0]), "cancer prostate 0 6;13 21 cancer de la prostate" + ) + text = "cancer du colon" + annots = self.matcher.annot_text(text=text) + self.assertEqual(1, len(annots)) + self.assertEqual(str(annots[0]), "cancer 0 6 cancer") + if __name__ == "__main__": unittest.main() From 378c9852e2f80ec55d8c48bbe24e9dc707fc017c Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sun, 5 Mar 2023 09:12:02 -0300 Subject: [PATCH 06/23] #13 add a (failing) test that shows a punctuation mark is removed by Brat Formatter --- tests/test_brat.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_brat.py b/tests/test_brat.py index cba660a..3548140 100644 --- a/tests/test_brat.py +++ b/tests/test_brat.py @@ -16,7 +16,9 @@ from iamsystem.matcher.matcher import Matcher from iamsystem.tokenization.api import IToken from iamsystem.tokenization.token import Offsets +from iamsystem.tokenization.tokenize import english_tokenizer from iamsystem.tokenization.tokenize import french_tokenizer +from iamsystem.tokenization.tokenize import split_find_iter_closure class BratUtilsTest(unittest.TestCase): @@ -312,6 +314,20 @@ def test_individual(self): annot.to_string(), "cancer prostate 0 6;7 15 cancer prostate" ) + def test_tokenformater_punctuation(self): + """Test punctuation is not removed by Brat Formatter. + https://github.com/scossin/iamsystem_python/issues/13 + """ + tokenizer = english_tokenizer() + tokenizer.split = split_find_iter_closure(pattern=r"(\w|\.|,)+") + matcher = Matcher.build( + keywords=["calcium 2.6 mmol/L"], tokenizer=tokenizer + ) + annots = matcher.annot_text(text="calcium 2.6 mmol/L") + self.assertEqual( + str(annots[0]), "calcium 2.6 mmol/L 0 18 calcium 2.6 mmol/L" + ) + if __name__ == "__main__": unittest.main() From 57538cc08f9d712eb7845592ca1416d4f3b444fd Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Tue, 7 Mar 2023 19:47:16 -0300 Subject: [PATCH 07/23] #13 Move brat_formatter setter to the class level. Encapsulate the brat_formatter in a class function 'annot_to_str' that generates a string representation of each annotation. Rename BratFormatters, create an enumerated list. Update documentation --- docs/source/api_doc.rst | 23 ++++++---- docs/source/brat.rst | 6 +-- src/iamsystem/__init__.py | 14 +++--- src/iamsystem/brat/adapter.py | 13 +++++- src/iamsystem/brat/formatter.py | 46 ++++++++++++++------ src/iamsystem/matcher/annotation.py | 67 ++++++++++++++++++----------- src/iamsystem/matcher/api.py | 20 +++++++-- src/iamsystem/matcher/matcher.py | 11 +++-- src/iamsystem/matcher/printannot.py | 36 ++++++++++++++++ src/iamsystem/matcher/strategy.py | 2 +- src/iamsystem/tokenization/span.py | 13 ------ tests/test_brat.py | 37 +++++++--------- tests/test_doc.py | 44 ++++++++++--------- 13 files changed, 213 insertions(+), 119 deletions(-) create mode 100644 src/iamsystem/matcher/printannot.py diff --git a/docs/source/api_doc.rst b/docs/source/api_doc.rst index 7e79260..efc27eb 100644 --- a/docs/source/api_doc.rst +++ b/docs/source/api_doc.rst @@ -281,23 +281,30 @@ Brat Formatter ^^^^^^^^^ -TokenFormatter +EBratFormatters +""""""""""""""" +.. autoclass:: iamsystem.EBratFormatters + :members: + :undoc-members: + :show-inheritance: + +ContSeqFormatter """""""""""""" -.. autoclass:: iamsystem.TokenFormatter +.. autoclass:: iamsystem.ContSeqFormatter :members: :undoc-members: :show-inheritance: -IndividualTokenFormatter -"""""""""""""""""""""""" -.. autoclass:: iamsystem.IndividualTokenFormatter +ContSeqStopFormatter +"""""""""""""" +.. autoclass:: iamsystem.ContSeqStopFormatter :members: :undoc-members: :show-inheritance: -TokenStopFormatter -"""""""""""""""""" -.. autoclass:: iamsystem.TokenStopFormatter +TokenFormatter +"""""""""""""" +.. autoclass:: iamsystem.TokenFormatter :members: :undoc-members: :show-inheritance: diff --git a/docs/source/brat.rst b/docs/source/brat.rst index 8b658d4..6f48d44 100644 --- a/docs/source/brat.rst +++ b/docs/source/brat.rst @@ -20,9 +20,9 @@ The default Brat formatter groups continuous sequence of tokens: :start-after: # start_test_brat_default_formatter :end-before: # end_test_brat_default_formatter -Indeed, "North America" has two tokens, "North" and "America" but a continuous annotation (0 13) is created. +Although "North America" has two tokens, "North" and "America", a continuous Brat annotation (0 13) is created. -In order to have one Brat span for each token, you can use the :ref:`api_doc:IndividualTokenFormatter`: +In order to have one Brat span for each token, you can use the :ref:`api_doc:TokenFormatter`: .. literalinclude:: ../../tests/test_doc.py :language: python @@ -32,7 +32,7 @@ In order to have one Brat span for each token, you can use the :ref:`api_doc:Ind :end-before: # end_test_brat_individual_formatter If you have stopwords in your matching sequences, you can include them in the Brat annotation using -:ref:`api_doc:TokenStopFormatter`. +:ref:`api_doc:ContSeqStopFormatter`. Stopwords are included if and only if they form a continuous sequence of tokens. Check the differences: diff --git a/src/iamsystem/__init__.py b/src/iamsystem/__init__.py index 9f837a2..6e28120 100644 --- a/src/iamsystem/__init__.py +++ b/src/iamsystem/__init__.py @@ -4,6 +4,7 @@ "IBaseMatcher", "Annotation", "IAnnotation", + "PrintAnnot", "rm_nested_annots", "IStopwords", "Stopwords", @@ -48,20 +49,22 @@ "SimStringWrapper", "ESimStringMeasure", "IBratFormatter", - "TokenFormatter", - "TokenStopFormatter", + "EBratFormatters", + "ContSeqFormatter", + "ContSeqStopFormatter", "SpanFormatter", - "IndividualTokenFormatter", + "TokenFormatter", ] from iamsystem.brat.adapter import BratDocument from iamsystem.brat.adapter import BratEntity from iamsystem.brat.adapter import BratNote from iamsystem.brat.adapter import BratWriter -from iamsystem.brat.formatter import IndividualTokenFormatter +from iamsystem.brat.formatter import ContSeqFormatter +from iamsystem.brat.formatter import ContSeqStopFormatter +from iamsystem.brat.formatter import EBratFormatters from iamsystem.brat.formatter import SpanFormatter from iamsystem.brat.formatter import TokenFormatter -from iamsystem.brat.formatter import TokenStopFormatter from iamsystem.fuzzy.abbreviations import Abbreviations from iamsystem.fuzzy.abbreviations import token_is_upper_case from iamsystem.fuzzy.api import ContextFreeAlgo @@ -90,6 +93,7 @@ from iamsystem.matcher.api import IBratFormatter from iamsystem.matcher.api import IMatcher from iamsystem.matcher.matcher import Matcher +from iamsystem.matcher.printannot import PrintAnnot from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.negative import NegativeStopwords from iamsystem.stopwords.simple import NoStopwords diff --git a/src/iamsystem/brat/adapter.py b/src/iamsystem/brat/adapter.py index 9f64927..6eada87 100644 --- a/src/iamsystem/brat/adapter.py +++ b/src/iamsystem/brat/adapter.py @@ -4,7 +4,9 @@ from typing import Iterable from typing import List +from iamsystem.brat.formatter import ContSeqFormatter from iamsystem.matcher.api import IAnnotation +from iamsystem.matcher.api import IBratFormatter class BratEntity: @@ -114,10 +116,17 @@ class BratDocument: one per line. See https://brat.nlplab.org/standoff.html """ - def __init__(self): + def __init__(self, brat_formatter: IBratFormatter = None): + """Create a Brat Document. + + :param brat_formatter: a strategy to create Brat annotations span, + like merging continuous sequence of tokens. Default BratFormatter + create a Brat span for each individual token. + """ self.brat_entities: List[BratEntity] = [] self.brat_notes: List[BratNote] = [] self.get_note: get_note_fun = get_note_keyword_label + self.brat_formatter = brat_formatter or ContSeqFormatter() def add_annots( self, @@ -145,7 +154,7 @@ def add_annots( b_type = annot.keywords[0].__getattribute__(keyword_attr) elif brat_type is not None: b_type = brat_type - text, offsets = annot.brat_formatter.get_text_and_offsets(annot) + text, offsets = self.brat_formatter.get_text_and_offsets(annot) brat_entity = BratEntity( entity_id=self._get_entity_id(), brat_type=b_type, diff --git a/src/iamsystem/brat/formatter.py b/src/iamsystem/brat/formatter.py index ccd5f48..7c06798 100644 --- a/src/iamsystem/brat/formatter.py +++ b/src/iamsystem/brat/formatter.py @@ -1,27 +1,37 @@ +from enum import Enum +from typing import List from typing import Tuple from iamsystem.brat.util import get_brat_format_seq from iamsystem.matcher.api import IAnnotation from iamsystem.matcher.api import IBratFormatter +from iamsystem.tokenization.api import IOffsets from iamsystem.tokenization.util import group_continuous_seq from iamsystem.tokenization.util import multiple_seq_to_offsets from iamsystem.tokenization.util import remove_trailing_stopwords -class TokenFormatter(IBratFormatter): +def get_text_span(text: str, offsets: IOffsets): + """Return the text substring of an offsets.""" + return text[offsets.start : offsets.end] # noqa + + +class ContSeqFormatter(IBratFormatter): """Default Brat Formatter: annotate a document by selecting continuous sequences of tokens but ignore stopwords.""" def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: """Return tokens' labels and token's offsets (merge if continuous)""" sequences = group_continuous_seq(tokens=annot.tokens) - offsets = multiple_seq_to_offsets(sequences=sequences) + offsets: List[IOffsets] = multiple_seq_to_offsets(sequences=sequences) seq_offsets = get_brat_format_seq(offsets) - seq_label = " ".join([token.label for token in annot.tokens]) + seq_label = " ".join( + [get_text_span(annot.text, one_offsets) for one_offsets in offsets] + ) return seq_label, seq_offsets -class IndividualTokenFormatter(IBratFormatter): +class TokenFormatter(IBratFormatter): """Annotate a document by creating (start,end) offsets for each token (In comparison to TokenFormatter, it doesn't merge continuous sequence).""" @@ -32,7 +42,7 @@ def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: return seq_label, seq_offsets -class TokenStopFormatter(IBratFormatter): +class ContSeqStopFormatter(IBratFormatter): """A Brat formatter that takes into account stopwords: annotate a document by selecting continuous sequences of tokens/stopwords.""" @@ -62,18 +72,26 @@ def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: class SpanFormatter(IBratFormatter): - """A simple Brat formatter that only uses start,end offsets + """A simple Brat formatter that only uses start, end offsets of an annotation""" - def __init__(self, text: str): - """Create a brat formatter. - - :param text: the document of the annotation. - """ - self.text = text - def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: """Return text, offsets by start and end offsets of the annotation.""" - seq_label = self.text[annot.start : annot.end] # noqa + seq_label = annot.text[annot.start : annot.end] # noqa seq_offsets = f"{annot.start} {annot.end}" return seq_label, seq_offsets + + +class EBratFormatters(Enum): + """An enumerated list of available Brat Formatters.""" + + DEFAULT = ContSeqFormatter() + "Default to CONTINUOUS_SEQ." + TOKEN = TokenFormatter() + "A fragment for each token." + CONTINUOUS_SEQ = ContSeqFormatter() + "Merge a continuous sequence of tokens but ignore stopwords." + CONTINUOUS_SEQ_STOP = ContSeqStopFormatter() + "Merge a continuous sequence of tokens with stopwords." + SPAN = SpanFormatter() + "A Brat annotation from first token start-offsets to last token end-offsets." # noqa diff --git a/src/iamsystem/matcher/annotation.py b/src/iamsystem/matcher/annotation.py index 4596e98..8764f31 100644 --- a/src/iamsystem/matcher/annotation.py +++ b/src/iamsystem/matcher/annotation.py @@ -2,17 +2,21 @@ import functools from typing import Any +from typing import Callable from typing import Dict from typing import Iterable from typing import List +from typing import Optional from typing import Sequence from typing import Tuple +from typing import Union -from iamsystem.brat.formatter import TokenFormatter +from iamsystem.brat.formatter import EBratFormatters from iamsystem.keywords.api import IEntity from iamsystem.keywords.api import IKeyword from iamsystem.matcher.api import IAnnotation from iamsystem.matcher.api import IBratFormatter +from iamsystem.matcher.printannot import PrintAnnot from iamsystem.matcher.util import LinkedState from iamsystem.tokenization.api import TokenT from iamsystem.tokenization.span import Span @@ -28,12 +32,33 @@ class Annotation(Span[TokenT], IAnnotation[TokenT]): """Ouput class of :class:`~iamsystem.Matcher` storing information on the detected entities.""" + annot_to_str: Callable[[IAnnotation], str] = PrintAnnot().annot_to_str + " A class function that generates a string representation of an annotation." # noqa + + @classmethod + def set_brat_formatter( + cls, brat_formatter: Union[EBratFormatters, IBratFormatter] + ): + """Change Brat Formatter to change text-span and offsets. + + :param brat_formatter: A Brat formatter to produce + a different Brat annotation. If None, default to + :class:`~iamsystem.ContSeqFormatter`. + :return: None + """ + if isinstance(brat_formatter, EBratFormatters): + brat_formatter = brat_formatter.value + cls.annot_to_str = PrintAnnot( + brat_formatter=brat_formatter + ).annot_to_str + def __init__( self, tokens: List[TokenT], algos: List[List[str]], last_state: INode, stop_tokens: List[TokenT], + text: Optional[str] = None, ): """Create an annotation. @@ -44,26 +69,27 @@ def __init__( :param last_state: a final state of iamsystem algorithm containing the keyword that matched this sequence of tokens. :param stop_tokens: the list of stopwords tokens of the document. + :param text: the annotated text/document. """ super().__init__(tokens) self._algos = algos self._last_state = last_state self._stop_tokens = stop_tokens - self._brat_formatter: IBratFormatter = TokenFormatter() + self._text = text @property - def algos(self) -> List[List[str]]: - return self._algos + def text(self) -> Optional[str]: + """Return the annotated text.""" + return self._text - @property - def brat_formatter(self) -> IBratFormatter: - """Return the Brat formatter.""" - return self._brat_formatter + @text.setter + def text(self, value: str) -> None: + """Set the annotated text.""" + self._text = value - @brat_formatter.setter - def brat_formatter(self, brat_formatter: IBratFormatter): - """Change the Brat formatter to produce a different Brat annotation""" - self._brat_formatter = brat_formatter + @property + def algos(self) -> List[List[str]]: + return self._algos @property def label(self): @@ -109,7 +135,6 @@ def to_dict(self, text: str = None) -> Dict[str, Any]: dic = { "start": self.start, "end": self.end, - "offsets": self.to_brat_format(), "label": self.label, "norm_label": self.tokens_norm_label, "tokens": [itoken_to_dict(token) for token in self.tokens], @@ -130,7 +155,7 @@ def __str__(self) -> str: """Annotation string representation with Brat offsets format.""" return f"{self.to_string()}" - def to_string(self, text: str = None, debug=False) -> str: + def to_string(self, text=False, debug=False) -> str: """Get a default string representation of this object. :param text: the document from which this annotation comes from. @@ -140,23 +165,15 @@ def to_string(self, text: str = None, debug=False) -> str: and fuzzyalgo names. :return: a concatenated string """ - text_span, offsets = self._brat_formatter.get_text_and_offsets( - annot=self - ) - columns = [text_span, offsets, self._keywords_to_string()] - if text is not None: - text_substring = text[self.start : self.end] # noqa + columns = [Annotation.annot_to_str(annot=self)] + if text: + text_substring = self.text[self.start : self.end] # noqa columns.append(text_substring) if debug: token_annots_str = self._get_norm_label_algos_str() columns.append(token_annots_str) return "\t".join(columns) - def _keywords_to_string(self): - """Merge the keywords.""" - keywords_str = [str(keyword) for keyword in self.keywords] - return ";".join(keywords_str) - def _get_norm_label_algos_str(self): """Get a string representation of tokens and algorithms.""" return ";".join( diff --git a/src/iamsystem/matcher/api.py b/src/iamsystem/matcher/api.py index 9cda5b1..8b70b02 100644 --- a/src/iamsystem/matcher/api.py +++ b/src/iamsystem/matcher/api.py @@ -1,5 +1,6 @@ """ Matcher's API.""" from typing import List +from typing import Optional from typing import Sequence from typing import Tuple @@ -34,8 +35,8 @@ def stop_tokens(self) -> List[TokenT]: raise NotImplementedError @property - def brat_formatter(self) -> "IBratFormatter": - """Access brat formatter.""" + def text(self) -> Optional[str]: + """Return the annotated text.""" raise NotImplementedError @property @@ -43,6 +44,11 @@ def keywords(self) -> Sequence[IKeyword]: """Keywords linked to this annotation.""" raise NotImplementedError + @property + def to_string(self) -> str: + """A string representation of an annotation.""" + raise NotImplementedError + @runtime_checkable class IBaseMatcher(Protocol): @@ -81,7 +87,15 @@ class IBratFormatter(Protocol): def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: """Return text (document substring) and annotation's offsets in the - Brat format""" + Brat format. + + :param annot: an annotation. + :return: A text span and its offsets: + 'The start-offset is the index of the first character of the + annotated span in the text (".txt" file), i.e. the number of + characters in the document preceding it. The end-offset is the index + of the first character after the annotated span.' + """ raise NotImplementedError diff --git a/src/iamsystem/matcher/matcher.py b/src/iamsystem/matcher/matcher.py index d7c680a..e7bb809 100644 --- a/src/iamsystem/matcher/matcher.py +++ b/src/iamsystem/matcher/matcher.py @@ -32,8 +32,8 @@ from iamsystem.keywords.collection import Terminology from iamsystem.keywords.keywords import Keyword from iamsystem.keywords.util import get_unigrams -from iamsystem.matcher.annotation import Annotation from iamsystem.matcher.annotation import rm_nested_annots +from iamsystem.matcher.api import IAnnotation from iamsystem.matcher.api import IMatcher from iamsystem.matcher.api import IMatchingStrategy from iamsystem.matcher.strategy import EMatchingStrategy @@ -290,18 +290,21 @@ def get_synonyms( synonyms: List[SynAlgos] = list(syns_collector.items()) return synonyms - def annot_text(self, text: str) -> List[Annotation[TokenT]]: + def annot_text(self, text: str) -> List[IAnnotation[TokenT]]: """Annotate a document. :param text: the document to annotate. :return: a list of :class:`~iamsystem.Annotation`. """ tokens: Sequence[TokenT] = self.tokenize(text) - return self.annot_tokens(tokens=tokens) + annots = self.annot_tokens(tokens=tokens) + for annot in annots: + annot.text = text + return annots def annot_tokens( self, tokens: Sequence[TokenT] - ) -> List[Annotation[TokenT]]: + ) -> List[IAnnotation[TokenT]]: """Annotate a sequence of tokens. :param tokens: an ordered or unordered sequence of tokens. diff --git a/src/iamsystem/matcher/printannot.py b/src/iamsystem/matcher/printannot.py new file mode 100644 index 0000000..e1a7b6e --- /dev/null +++ b/src/iamsystem/matcher/printannot.py @@ -0,0 +1,36 @@ +""" An utility class to build multiple "print annotation" strategies +depending on the chosen BratFormatter.""" +from iamsystem.brat.formatter import ContSeqFormatter +from iamsystem.brat.formatter import IBratFormatter +from iamsystem.brat.formatter import TokenFormatter +from iamsystem.matcher.api import IAnnotation + + +class PrintAnnot: + def __init__(self, brat_formatter: IBratFormatter = None): + """Create a FormatAnnot instance to change annot to_string behavior. + + :param brat_formatter: A Brat formatter to produce + a different Brat annotation. If None, default to + :class:`~iamsystem.ContSeqFormatter`. + """ + self._brat_formatter = brat_formatter or ContSeqFormatter() + + def annot_to_str(self, annot: IAnnotation): + """Return a string representation of this annotation. + + :param annot: An annotation. + :return: A concatenation of annotation 'text-span', 'offsets' and + 'keywords' separated by a tabulation. + text-span and offsets are generated by the BratFormatter. + """ + brat_formatter = self._brat_formatter + # IndividualTokenFormatter doesn't use annotation 'text' to produce + # a valid Brat offsets. The other formatter must not be used if text + # is not set. + if annot.text is None: + brat_formatter = TokenFormatter() + text_span, offsets = brat_formatter.get_text_and_offsets(annot=annot) + keywords_str = [str(keyword) for keyword in annot.keywords] + columns = [text_span, offsets, ";".join(keywords_str)] + return "\t".join(columns) diff --git a/src/iamsystem/matcher/strategy.py b/src/iamsystem/matcher/strategy.py index d8cf385..81652fd 100644 --- a/src/iamsystem/matcher/strategy.py +++ b/src/iamsystem/matcher/strategy.py @@ -320,7 +320,7 @@ def detect( ) i = last_i + 1 started_at = started_at + 1 - states = set() + states.clear() states.add(start_state) # All tokens have been seen. Create last annotations if any states: self._add_annots( diff --git a/src/iamsystem/tokenization/span.py b/src/iamsystem/tokenization/span.py index 4ecf9a6..827dea4 100644 --- a/src/iamsystem/tokenization/span.py +++ b/src/iamsystem/tokenization/span.py @@ -1,7 +1,6 @@ """ Classes that store a sequence of tokens. """ from typing import List -from iamsystem.brat.util import get_brat_format_seq from iamsystem.tokenization.api import IOffsets from iamsystem.tokenization.api import ISpan from iamsystem.tokenization.api import TokenT @@ -46,18 +45,6 @@ def end_i(self): """The index of the last token within the parent document.""" return self.tokens[-1].i - def to_brat_format(self) -> str: - """Get Brat offsets format. See https://brat.nlplab.org/standoff.html - 'The start-offset is the index of the first character of the annotated - span in the text (".txt" file), - i.e. the number of characters in the document preceding it. - The end-offset is the index of the first character - after the annotated span.' - - :return: a string format of tokens' offsets - """ - return get_brat_format_seq(self._tokens) - @property def tokens_label(self): """The concatenation of each token's label.""" diff --git a/tests/test_brat.py b/tests/test_brat.py index 3548140..b7c7df3 100644 --- a/tests/test_brat.py +++ b/tests/test_brat.py @@ -7,12 +7,12 @@ from iamsystem.brat.adapter import BratEntity from iamsystem.brat.adapter import BratNote from iamsystem.brat.adapter import BratWriter -from iamsystem.brat.formatter import IndividualTokenFormatter -from iamsystem.brat.formatter import SpanFormatter -from iamsystem.brat.formatter import TokenStopFormatter +from iamsystem.brat.formatter import ContSeqStopFormatter +from iamsystem.brat.formatter import EBratFormatters from iamsystem.brat.util import get_brat_format from iamsystem.brat.util import get_brat_format_seq from iamsystem.keywords.keywords import Keyword +from iamsystem.matcher.annotation import Annotation from iamsystem.matcher.matcher import Matcher from iamsystem.tokenization.api import IToken from iamsystem.tokenization.token import Offsets @@ -83,27 +83,15 @@ def test_bad_entity_id(self): text=self.text, ) - def test_to_brat_format(self): - """to_brat_format function performs a per token annotation.""" - matcher = Matcher.build( - keywords=["cancer prostate"], stopwords=["de", "la"], w=2 - ) - annots = matcher.annot_text(text="cancer de la prostate") - self.assertEqual(annots[0].to_brat_format(), "0 6;13 21") - self.assertEqual( - str(annots[0]), "cancer prostate 0 6;13 21 cancer prostate" - ) - def test_to_brat_format_leading_stop(self): """Leading stopwords are removed from a discontinuous sequence.""" matcher = Matcher.build( keywords=["cancer prostate"], stopwords=["de", "la"], w=2 ) annots = matcher.annot_text(text="cancer de la glande prostate") - self.assertEqual(annots[0].to_brat_format(), "0 6;20 28") self.assertEqual( str(annots[0]), "cancer prostate 0 6;20 28 cancer prostate" - ) # noqa + ) class BraNoteTest(unittest.TestCase): @@ -257,6 +245,9 @@ def test_similar_stopwords_window_strategy(self): class BratFormatterTest(unittest.TestCase): + def tearDown(self) -> None: + Annotation.set_brat_formatter(brat_formatter=EBratFormatters.DEFAULT) + def setUp(self) -> None: self.matcher = Matcher.build( keywords=["cancer prostate"], stopwords=["de", "la"], w=2 @@ -274,7 +265,7 @@ def test_default(self): def test_stop_true(self): """BratTokenAndStop remove trailing sequence of stopwords. Here 'de', 'la' that are trailing thus removed.""" - self.annot.brat_formatter = TokenStopFormatter() # default True + self.annot.brat_formatter = ContSeqStopFormatter() # default True self.assertEqual( self.annot.to_string(), "cancer prostate 0 6;20 28 cancer prostate" ) @@ -284,14 +275,18 @@ def test_stop_true_2(self): Here 'de', 'la' that are not trailing thus not removed.""" annots = self.matcher.annot_text(text="cancer de la prostate") annot = annots[0] - annot.brat_formatter = TokenStopFormatter() # default True + Annotation.set_brat_formatter( + brat_formatter=EBratFormatters.CONTINUOUS_SEQ_STOP + ) self.assertEqual( annot.to_string(), "cancer de la prostate 0 21 cancer prostate" ) def test_stop_false(self): """Keep stopwords inside annotation, 'de', 'la' are present.""" - self.annot.brat_formatter = TokenStopFormatter(False) + Annotation.set_brat_formatter( + brat_formatter=ContSeqStopFormatter(False) + ) self.assertEqual( self.annot.to_string(), "cancer de la prostate 0 12;20 28 cancer prostate", @@ -299,7 +294,7 @@ def test_stop_false(self): def test_span(self): """Simply take start and end offsets of the annotation.""" - self.annot.brat_formatter = SpanFormatter(text=self.text) + Annotation.set_brat_formatter(brat_formatter=EBratFormatters.SPAN) self.assertEqual( self.annot.to_string(), "cancer de la glande prostate 0 28 cancer prostate", @@ -307,9 +302,9 @@ def test_span(self): def test_individual(self): """Check it outputs offsets for each token.""" + Annotation.set_brat_formatter(brat_formatter=EBratFormatters.TOKEN) annots = self.matcher.annot_text(text="cancer prostate") annot = annots[0] - annot.brat_formatter = IndividualTokenFormatter() self.assertEqual( annot.to_string(), "cancer prostate 0 6;7 15 cancer prostate" ) diff --git a/tests/test_doc.py b/tests/test_doc.py index 2f50ff6..2072b9f 100644 --- a/tests/test_doc.py +++ b/tests/test_doc.py @@ -201,11 +201,9 @@ def test_matcher_with_custom_tokenizer(self): annots = matcher.annot_text(text=text) for annot in annots: print(annot) - # sars cov + 51 60 SARS-CoV+ (95209-3) + # sars-cov + 51 60 SARS-CoV+ (95209-3) # end_test_matcher_with_custom_tokenizer - self.assertEqual( - "sars cov + 51 60 SARS-CoV+ (95209-3)", str(annots[0]) - ) + self.assertEqual("sars-cov+ 51 60 SARS-CoV+ (95209-3)", str(annots[0])) def test_unordered_words_seq(self): """Tokenizer orders the tokens to have a match when the order of @@ -418,6 +416,12 @@ def test_annotation_partial_overlap(self): class BratDocTest(unittest.TestCase): + def tearDown(self) -> None: + from iamsystem import Annotation + from iamsystem import EBratFormatters + + Annotation.set_brat_formatter(brat_formatter=EBratFormatters.DEFAULT) + def test_brat_document(self): """Brat document example.""" # start_test_brat_document @@ -519,14 +523,14 @@ def test_brat_default_formatter(self): def test_brat_individual_formatter(self): """Show token per token formatter""" # start_test_brat_individual_formatter - from iamsystem import IndividualTokenFormatter + from iamsystem import Annotation + from iamsystem import EBratFormatters from iamsystem import Matcher + Annotation.set_brat_formatter(brat_formatter=EBratFormatters.TOKEN) matcher = Matcher.build(keywords=["North America"]) annots = matcher.annot_text(text="North America") - formatter = IndividualTokenFormatter() for annot in annots: - annot.brat_formatter = formatter print(annot) # North America 0 5;6 13 North America # end_test_brat_individual_formatter @@ -537,44 +541,44 @@ def test_brat_individual_formatter(self): def test_brat_tokenstop_formatter(self): """Show adding stopwords when continuous in the sequence""" # start_test_brat_tokenstop_formatter + from iamsystem import Annotation + from iamsystem import EBratFormatters from iamsystem import Entity from iamsystem import Matcher - from iamsystem import TokenStopFormatter matcher = Matcher.build( keywords=[Entity(label="cancer de prostate", kb_id="C61")], stopwords=["de", "la"], ) annots = matcher.annot_text(text="cancer de la prostate") - formatter = TokenStopFormatter() - for annot in annots: - print(f"Default formatter: {annot}") - annot.brat_formatter = formatter - print(f"TokenStop formatter: {annot}") + print(f"Default formatter: {annots[0]}") + Annotation.set_brat_formatter( + brat_formatter=EBratFormatters.CONTINUOUS_SEQ_STOP + ) + print(f"Default formatter: {annots[0]}") # Default formatter: cancer prostate 0 6;13 21 cancer de prostate (C61) # noqa # TokenStop formatter: cancer de la prostate 0 21 cancer de prostate (C61) # noqa # end_test_brat_tokenstop_formatter self.assertEqual( str(annots[0]), - "cancer de la prostate 0 21 cancer de " "prostate (C61)", + "cancer de la prostate 0 21 cancer de prostate (C61)", ) def test_brat_span_formatter(self): """Show creating a span from start to end of the annotation.""" # start_test_brat_span_formatter + from iamsystem import Annotation + from iamsystem import EBratFormatters from iamsystem import Matcher - from iamsystem import SpanFormatter matcher = Matcher.build( keywords=["North America"], stopwords=["and"], w=2 ) text = "North and South America" annots = matcher.annot_text(text=text) - formatter = SpanFormatter(text=text) - for annot in annots: - print(f"Default formatter: {annot}") - annot.brat_formatter = formatter - print(f"Span formatter: {annot}") + print(f"Default formatter: {annots[0]}") + Annotation.set_brat_formatter(brat_formatter=EBratFormatters.SPAN) + print(f"Span formatter: {annots[0]}") # Default formatter: North America 0 5;16 23 North America # Span formatter: North and South America 0 23 North America # end_test_brat_span_formatter From 32b3a78326740d6db792696341e6663f260c9ca7 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Tue, 7 Mar 2023 22:49:32 -0300 Subject: [PATCH 08/23] Doc: add matching strategy documentation --- docs/source/api_doc.rst | 6 +++++ docs/source/matcher.rst | 29 +++++++++++++++++++++++ src/iamsystem/__init__.py | 2 ++ src/iamsystem/matcher/matcher.py | 5 ++-- src/iamsystem/matcher/strategy.py | 38 +++++++++---------------------- tests/test_doc.py | 17 ++++++++++++++ 6 files changed, 68 insertions(+), 29 deletions(-) diff --git a/docs/source/api_doc.rst b/docs/source/api_doc.rst index efc27eb..1e9da26 100644 --- a/docs/source/api_doc.rst +++ b/docs/source/api_doc.rst @@ -20,6 +20,12 @@ Matcher build :members: build :noindex: +EMatchingStrategy +^^^^^^^^^^^^^^^^^ +.. autoclass:: iamsystem.EMatchingStrategy + :members: + :noindex: + Span ---- .. autoclass:: iamsystem.matcher.annotation.Span diff --git a/docs/source/matcher.rst b/docs/source/matcher.rst index f01c125..1f38e08 100644 --- a/docs/source/matcher.rst +++ b/docs/source/matcher.rst @@ -81,3 +81,32 @@ the algorithm fails to detect it. For example: This problem can be solved by changing the order of the tokens in a sentence which is the responsibility of the tokenizer. See Tokenizer section on :ref:`tokenizer:Change tokens order`. + +Matching strategies +^^^^^^^^^^^^^^^^^^^ + +The matching strategy is the core of the IAMsystem algorithm. +There are currently two different strategies: *window matching* and *NoOverlap* (:ref:`api_doc:EMatchingStrategy`). +The *NoOverlap* strategy is the fastest one since it does not take into account the window parameter +and does not produce any overlap (except in case of an ambiguity). +The window strategy is slightly slower but allows the detection of discontinuous tokens sequence and +nested annotations. + +Window Matching +""""""""""""""" + +This is the default strategy. It is used in all the examples of this documentation. +When the window size is small, the number of operations depends little on the number of keywords. +As the window increases, the number of operations grows and may become proportional to n*m with n the number of +tokens in the document and m the number of keywords. +The *LargeWindowMatching* strategy trades space for time complexity, it produces exactly the same annotations as the +WindowMatching strategy with a number of operations proportional to n*log(m). +*LargeWindowMatching* is slower than the *WindowMatching* when w is small but much faster when w is large, +for example when w=1000. + +No Overlap +"""""""""" + +The *NoOverlap* matching strategy was the first matching strategy implemented by IAMsystem and was described in research papers. +It only uses a window of 1 (window parameter has no effect) and doesn't detect nested annotations. +Although this strategy is limited in scope, it's the fastest. diff --git a/src/iamsystem/__init__.py b/src/iamsystem/__init__.py index 6e28120..233b74b 100644 --- a/src/iamsystem/__init__.py +++ b/src/iamsystem/__init__.py @@ -2,6 +2,7 @@ "Matcher", "IMatcher", "IBaseMatcher", + "EMatchingStrategy", "Annotation", "IAnnotation", "PrintAnnot", @@ -94,6 +95,7 @@ from iamsystem.matcher.api import IMatcher from iamsystem.matcher.matcher import Matcher from iamsystem.matcher.printannot import PrintAnnot +from iamsystem.matcher.strategy import EMatchingStrategy from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.negative import NegativeStopwords from iamsystem.stopwords.simple import NoStopwords diff --git a/src/iamsystem/matcher/matcher.py b/src/iamsystem/matcher/matcher.py index e7bb809..d53fe9e 100644 --- a/src/iamsystem/matcher/matcher.py +++ b/src/iamsystem/matcher/matcher.py @@ -38,7 +38,6 @@ from iamsystem.matcher.api import IMatchingStrategy from iamsystem.matcher.strategy import EMatchingStrategy from iamsystem.matcher.strategy import WindowMatching -from iamsystem.matcher.strategy import buildMatchingStrategy from iamsystem.matcher.util import LinkedState from iamsystem.stopwords.api import ISimpleStopwords from iamsystem.stopwords.api import IStopwords @@ -399,7 +398,9 @@ def build( # Configure annot_text function matcher.w = w matcher.remove_nested_annots = remove_nested_annots - matcher.strategy = buildMatchingStrategy(strategy=strategy) + if isinstance(strategy, str): + strategy = EMatchingStrategy[strategy.upper()] + matcher.strategy = strategy.value # Add the keywords matcher.add_keywords(keywords=keywords) diff --git a/src/iamsystem/matcher/strategy.py b/src/iamsystem/matcher/strategy.py index 81652fd..81e758d 100644 --- a/src/iamsystem/matcher/strategy.py +++ b/src/iamsystem/matcher/strategy.py @@ -5,7 +5,6 @@ from typing import List from typing import Sequence from typing import Set -from typing import Union from iamsystem.fuzzy.api import ISynsProvider from iamsystem.fuzzy.api import SynAlgos @@ -22,32 +21,6 @@ from iamsystem.tree.nodes import INode -class EMatchingStrategy(Enum): - """Enumeration of matching strategies.""" - - WINDOW = "WINDOW" - LARGE_WINDOW = "LARGE_WINDOW" - NO_OVERLAP = "NO_OVERLAP" - - -def buildMatchingStrategy( - strategy: Union[str, EMatchingStrategy] -) -> IMatchingStrategy: - """Matching strategy factory. - - :param strategy: the name of the value strategy. - :return: a matching strategy. - """ - if isinstance(strategy, str): - strategy = EMatchingStrategy[strategy.upper()] - if strategy == EMatchingStrategy.WINDOW: - return WindowMatching() - if strategy == EMatchingStrategy.LARGE_WINDOW: - return LargeWindowMatching() - if strategy == EMatchingStrategy.NO_OVERLAP: - return NoOverlapMatching() - - class WindowMatching(IMatchingStrategy): """Default matching strategy. It keeps track of all states within a window range and can be produce @@ -366,3 +339,14 @@ def _add_annots( last_annot_i = max(annot.end_i, last_annot_i) annots.append(annot) return max(started_at, last_annot_i) + + +class EMatchingStrategy(Enum): + """Enumeration of matching strategies.""" + + WINDOW = WindowMatching() + " Default matching strategy. " + LARGE_WINDOW = LargeWindowMatching() + " Same annotations as Window but faster than window is large. " + NO_OVERLAP = NoOverlapMatching() + " No overlap/nested annotations, fastest strategies." diff --git a/tests/test_doc.py b/tests/test_doc.py index 2072b9f..39034ad 100644 --- a/tests/test_doc.py +++ b/tests/test_doc.py @@ -134,6 +134,23 @@ def test_window(self): "calcium level 0 7;14 19 calcium level", str(annots[0]) ) + def test_no_overlap_strategy(self): + """Simple NoOverlap Strategy example.""" + # start_test_no_overlap_strategy + from iamsystem import EMatchingStrategy + from iamsystem import Matcher + + matcher = Matcher.build( + keywords=["North America", "South America"], + strategy=EMatchingStrategy.NO_OVERLAP, + ) + annots = matcher.annot_text(text="North and South America") + for annot in annots: + print(annot) + # South America 10 23 South America + # end_test_no_overlap_strategy + self.assertEqual("South America 10 23 South America", str(annots[0])) + def test_fail_order(self): """Matcher fails to detect when tokens order is not the same in keywords and document.""" From 56ef1478df9a810ee04c2dd719681472e134c089 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Thu, 9 Mar 2023 18:58:00 -0300 Subject: [PATCH 09/23] NoOverlap strategy: add a failing test showing that, at the end of the document, the algorithm doesn't backtrack. call to add_annots function is not enough. --- tests/test_matcher.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 60c4f84..463a058 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -513,7 +513,7 @@ def test_no_overlap_strategy_back_track(self): self.assertEqual(str(annots[2]), "prostate 28 36 prostate") def test_no_overlap_strategy_stopword(self): - """Test the streatgy words with stopwords""" + """Test the strategy words with stopwords""" self.matcher = Matcher.build( keywords=["cancer", "cancer de la prostate"], stopwords=["de", "la"], @@ -530,6 +530,18 @@ def test_no_overlap_strategy_stopword(self): self.assertEqual(1, len(annots)) self.assertEqual(str(annots[0]), "cancer 0 6 cancer") + def test_no_overlap_end_token(self): + """Test 'END_TOKEN' works: at the last token 'instutionnelle' + it reaches the 'END_TOKEN' and needs to back-track to the token 'de' + in order to detect medecine.""" + self.matcher = Matcher.build( + keywords=["portail de la médecine instutionnelle", "médecine"], + strategy="no_overlap", + ) + text = "Portail de la médecine" + annots = self.matcher.annot_text(text=text) + self.assertEqual(1, len(annots)) + if __name__ == "__main__": unittest.main() From f25f3720922352c5eba1c164d45898f25c810871 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Thu, 9 Mar 2023 18:58:51 -0300 Subject: [PATCH 10/23] NoOverlap strategy: introduce a 'END_TOKEN' to allow back-tracking at the end of a document. #fix failing test of previous commit. --- src/iamsystem/matcher/matcher.py | 1 + src/iamsystem/matcher/strategy.py | 29 ++++++++++++++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/iamsystem/matcher/matcher.py b/src/iamsystem/matcher/matcher.py index d53fe9e..609f550 100644 --- a/src/iamsystem/matcher/matcher.py +++ b/src/iamsystem/matcher/matcher.py @@ -398,6 +398,7 @@ def build( # Configure annot_text function matcher.w = w matcher.remove_nested_annots = remove_nested_annots + if isinstance(strategy, str): strategy = EMatchingStrategy[strategy.upper()] matcher.strategy = strategy.value diff --git a/src/iamsystem/matcher/strategy.py b/src/iamsystem/matcher/strategy.py index 81e758d..e1d3c9b 100644 --- a/src/iamsystem/matcher/strategy.py +++ b/src/iamsystem/matcher/strategy.py @@ -17,6 +17,7 @@ from iamsystem.matcher.util import create_start_state from iamsystem.stopwords.api import IStopwords from iamsystem.tokenization.api import TokenT +from iamsystem.tokenization.tokenize import Token from iamsystem.tree.nodes import EMPTY_NODE from iamsystem.tree.nodes import INode @@ -223,6 +224,14 @@ class NoOverlapMatching(IMatchingStrategy): Algorithm formalized in https://ceur-ws.org/Vol-3202/livingner-paper11.pdf # noqa """ + END_TOKEN = Token( + start=-1, + end=-1, + label="IAMSYSTEM_END_TOKEN", + norm_label="IAMSYSTEM_END_TOKEN", + i=-1, + ) + def detect( self, tokens: Sequence[TokenT], @@ -245,8 +254,13 @@ def detect( # started_at is used for back-tracking, it stores the initial 'i' # from which the initial state started. started_at = 0 - while i < len(tokens): - token = tokens[i] + # I create a copy of the tokens sequence and append 'END_TOKEN' + # The goal of this 'END_TOKEN' is to generate a dead end for the last + # new_states which force to enter case '2)' below. + tokens_copy = list(tokens) + tokens_copy.append(NoOverlapMatching.END_TOKEN) + while i < len(tokens_copy): + token = tokens_copy[i] if stopwords.is_token_a_stopword(token): stop_tokens.append(token) i += 1 @@ -271,12 +285,12 @@ def detect( w_bucket=-1, ) new_states.add(new_state) - # Case the algorithm is exploring a path: + # 1) Case the algorithm is exploring a path: if len(new_states) != 0: states = new_states i += 1 # don't 'started_at += 1' to allow backtracking later. - # Case the algorithm has finished exploring a path: + # 2) Case the algorithm has finished exploring a path: else: # the algorithm has gone nowhere from initial state: if len(states) == 1 and start_state in states: @@ -295,13 +309,6 @@ def detect( started_at = started_at + 1 states.clear() states.add(start_state) - # All tokens have been seen. Create last annotations if any states: - self._add_annots( - annots=annots, - states=states, - started_at=started_at, - stop_tokens=stop_tokens, - ) sort_annot(annots) # mutate the list like annots.sort() return annots From 707b21bc636c16dc0ae0b3655bebc4e1234b4913 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Fri, 10 Mar 2023 17:40:55 -0300 Subject: [PATCH 11/23] #14 annotation across new lines - add a (failing) test --- tests/test_brat.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_brat.py b/tests/test_brat.py index b7c7df3..11565b8 100644 --- a/tests/test_brat.py +++ b/tests/test_brat.py @@ -323,6 +323,15 @@ def test_tokenformater_punctuation(self): str(annots[0]), "calcium 2.6 mmol/L 0 18 calcium 2.6 mmol/L" ) + def test_brat_sentence_break(self): + """Check when an annotation spans a new line it doesn't print multiple + lines.""" + matcher = Matcher.build(keywords=["cancer du poumon"]) + annots = matcher.annot_text("""cancer du\npoumon""") + self.assertEqual( + str(annots[0]), "cancer du\\npoumon 0 16 cancer du poumon" + ) + if __name__ == "__main__": unittest.main() From fe3febf230fbd3c85910c1c99b255948ffbf98bf Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Fri, 10 Mar 2023 17:49:41 -0300 Subject: [PATCH 12/23] #14 escape new line '\n' in to_string Annotation method and in Brat entity text --- src/iamsystem/brat/adapter.py | 2 +- src/iamsystem/matcher/annotation.py | 2 +- tests/test_brat.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/iamsystem/brat/adapter.py b/src/iamsystem/brat/adapter.py index 6eada87..707a2ed 100644 --- a/src/iamsystem/brat/adapter.py +++ b/src/iamsystem/brat/adapter.py @@ -159,7 +159,7 @@ def add_annots( entity_id=self._get_entity_id(), brat_type=b_type, offsets=offsets, - text=text, + text=text.replace("\n", "\\n"), ) self.brat_entities.append(brat_entity) diff --git a/src/iamsystem/matcher/annotation.py b/src/iamsystem/matcher/annotation.py index 8764f31..5088a67 100644 --- a/src/iamsystem/matcher/annotation.py +++ b/src/iamsystem/matcher/annotation.py @@ -172,7 +172,7 @@ def to_string(self, text=False, debug=False) -> str: if debug: token_annots_str = self._get_norm_label_algos_str() columns.append(token_annots_str) - return "\t".join(columns) + return "\t".join(columns).replace("\n", "\\n") def _get_norm_label_algos_str(self): """Get a string representation of tokens and algorithms.""" diff --git a/tests/test_brat.py b/tests/test_brat.py index 11565b8..7f50c51 100644 --- a/tests/test_brat.py +++ b/tests/test_brat.py @@ -331,6 +331,10 @@ def test_brat_sentence_break(self): self.assertEqual( str(annots[0]), "cancer du\\npoumon 0 16 cancer du poumon" ) + self.assertEqual( + annots[0].to_string(text=True), + "cancer du\\npoumon 0 16 cancer du poumon cancer du\\npoumon", + ) if __name__ == "__main__": From 32721875c0c0a9ee8e728b5024fb06f3af642d9e Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Fri, 10 Mar 2023 18:37:35 -0300 Subject: [PATCH 13/23] #15 negative stopwords with fuzzy algos: add a (failing) test to show it doesn't work --- tests/test_matcher.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 463a058..0b444df 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -542,6 +542,24 @@ def test_no_overlap_end_token(self): annots = self.matcher.annot_text(text=text) self.assertEqual(1, len(annots)) + def test_fuzzy_algorithms_with_negative_stopwords(self): + """Check fuzzy algorithms are working with negative stopwords. + Here check it works with Levenshtein.""" + from iamsystem import Matcher + + matcher = Matcher.build( + keywords=["cancer du poumon"], + stopwords=["du"], + negative=True, + w=1, + abbreviations=[("k", "cancer")], + spellwise=[ + dict(measure=ESpellWiseAlgo.LEVENSHTEIN, max_distance=1) + ], + ) + annots = matcher.annot_text(text="k poumons") + self.assertEqual(1, len(annots)) + if __name__ == "__main__": unittest.main() From f6898cde83ac3d788addfaf4bf413a07b29305b5 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Fri, 10 Mar 2023 18:40:32 -0300 Subject: [PATCH 14/23] Add an important comment about 'get_syns_of_word' of Cache class --- src/iamsystem/fuzzy/cache.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/iamsystem/fuzzy/cache.py b/src/iamsystem/fuzzy/cache.py index 3afc9cf..aa942ed 100644 --- a/src/iamsystem/fuzzy/cache.py +++ b/src/iamsystem/fuzzy/cache.py @@ -53,6 +53,9 @@ def get_synonyms( word = token.norm_label return self.get_syns_of_word(word=word) + # Note get_syns_of_word returns a List of SynAlgo and not SynType + # like get_syns_of_word of NormalLabel instances does. + # It might be smarter to rename this function. def get_syns_of_word(self, word: str) -> List[SynAlgo]: """Retrieve all synonyms of fuzzy algorithms from cache or by calling them once.""" From db2ce6913864d4f21267137abd2cf5e737e11f1e Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Fri, 10 Mar 2023 19:06:45 -0300 Subject: [PATCH 15/23] #15 negative stopwords with fuzzy: create a function for a NegativeStopwords instance that calls fuzzy algorithms and checks any synonym is returned. If true then the current token shouldn't be ignored. Fix #15 --- src/iamsystem/matcher/matcher.py | 27 ++++++++++------ src/iamsystem/stopwords/negative.py | 48 +++++++++++++++++++++++++++++ tests/test_matcher.py | 4 ++- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/iamsystem/matcher/matcher.py b/src/iamsystem/matcher/matcher.py index 609f550..b557db8 100644 --- a/src/iamsystem/matcher/matcher.py +++ b/src/iamsystem/matcher/matcher.py @@ -43,6 +43,7 @@ from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.api import IStoreStopwords from iamsystem.stopwords.negative import NegativeStopwords +from iamsystem.stopwords.negative import is_a_w_2_keep_fuzzy_closure from iamsystem.stopwords.simple import NoStopwords from iamsystem.stopwords.simple import Stopwords from iamsystem.tokenization.api import ITokenizer @@ -394,6 +395,7 @@ def build( matcher.add_stopwords(words=stopwords) elif isinstance(stopwords, IStopwords): matcher.stopwords = stopwords + first_stopwords_instance = matcher.stopwords # Configure annot_text function matcher.w = w @@ -414,7 +416,6 @@ def build( ) # fuzzy algorithms parameterization - def _add_algo_in_cache_closure( cache: CacheFuzzyAlgos, matcher: Matcher ): @@ -459,14 +460,6 @@ def add_algo_in_cache(algo=INormLabelAlgo): for params in fuzzy_regex: fuzzy = FuzzyRegex(**params) add_algo_in_cache(algo=fuzzy) - if negative: - negative_stopwords = typing.cast( - NegativeStopwords, - matcher.stopwords, - ) - negative_stopwords.add_fun_is_a_word_to_keep( - fuzzy.token_matches_pattern - ) # String Distances # words ignored by string distance algorithms @@ -495,5 +488,19 @@ def add_algo_in_cache(algo=INormLabelAlgo): **params, ) add_algo_in_cache(algo=ss_algo) - # matcher.strategy = LargeWindowDetector(matcher.get_initial_state()) + + # if negative stopwords is used: + # add a function that calls fuzzy algorithms to check if any + # synonym is returned by them. + # https://github.com/scossin/iamsystem_python/issues/15 + if negative: + is_a_w_2_keep_fuzzy = is_a_w_2_keep_fuzzy_closure( + fuzzy_algos=matcher.fuzzy_algos, + stopwords=first_stopwords_instance, + ) + negative_stopwords = typing.cast( + NegativeStopwords, + matcher.stopwords, + ) + negative_stopwords.add_fun_is_a_word_to_keep(is_a_w_2_keep_fuzzy) return matcher diff --git a/src/iamsystem/stopwords/negative.py b/src/iamsystem/stopwords/negative.py index a7efb03..a051415 100644 --- a/src/iamsystem/stopwords/negative.py +++ b/src/iamsystem/stopwords/negative.py @@ -5,6 +5,10 @@ from typing import Optional from typing import Set +from iamsystem.fuzzy.api import ContextFreeAlgo +from iamsystem.fuzzy.api import FuzzyAlgo +from iamsystem.fuzzy.cache import CacheFuzzyAlgos +from iamsystem.fuzzy.exact import ExactMatch from iamsystem.stopwords.api import IStopwords from iamsystem.tokenization.api import TokenT @@ -62,3 +66,47 @@ def is_token_a_stopword(self, token: TokenT) -> bool: ) is_a_word_to_keep = fun_want_to_keep_it or word in self.words_to_keep return not is_a_word_to_keep + + +def is_a_w_2_keep_fuzzy_closure( + fuzzy_algos: Iterable[FuzzyAlgo[TokenT]], stopwords: IStopwords +): + """Returns a function that checks if fuzzy algorithms return any synonym, + thus that the token evaluated by NegativeStopwords is not a stopword. + + :param fuzzy_algos: an iterable of fuzzy algorithms. + :param stopwords: a stopwords instance + :return: a function that can be used by a Negative Stopwords instance + to check if a token can be matched to a keyword's unigram, which means + it shouldn't be ignored. + """ + context_free_algos: List[ContextFreeAlgo[TokenT]] = [ + algo + for algo in fuzzy_algos + if isinstance(algo, ContextFreeAlgo) + and not isinstance(algo, ExactMatch) + ] + + cache: List[CacheFuzzyAlgos[TokenT]] = [ + algo for algo in fuzzy_algos if isinstance(algo, CacheFuzzyAlgos) + ] + + def is_a_word_2_keep_according_to_fuzzy(token: TokenT) -> bool: + """A function to pass to a negative stopwords instance.""" + if stopwords.is_token_a_stopword(token): + return False + syns_context_free = [ + syn + for algo in context_free_algos + for syn in algo.get_syns_of_token(token=token) + if syn != FuzzyAlgo.NO_SYN + ] + syns_cache = [ + syn + for algo in cache + for syn in algo.get_syns_of_word(token.norm_label) + ] + + return len(syns_context_free) != 0 or len(syns_cache) != 0 + + return is_a_word_2_keep_according_to_fuzzy diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 0b444df..7473458 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -544,7 +544,9 @@ def test_no_overlap_end_token(self): def test_fuzzy_algorithms_with_negative_stopwords(self): """Check fuzzy algorithms are working with negative stopwords. - Here check it works with Levenshtein.""" + Here check it works with Levenshtein. + https://github.com/scossin/iamsystem_python/issues/15 + """ from iamsystem import Matcher matcher = Matcher.build( From ec9aea1b998d12fd0ed231a97d38706ef30ca1bb Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Fri, 10 Mar 2023 19:35:37 -0300 Subject: [PATCH 16/23] Docs: improve documentation and add images --- docs/source/_static/largeWindowSize.png | Bin 0 -> 21211 bytes docs/source/_static/no_overlap.png | Bin 0 -> 58942 bytes docs/source/_static/window_1.png | Bin 0 -> 59522 bytes docs/source/_static/window_2.png | Bin 0 -> 63955 bytes docs/source/matcher.rst | 62 ++++++++++++++++++------ 5 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 docs/source/_static/largeWindowSize.png create mode 100644 docs/source/_static/no_overlap.png create mode 100644 docs/source/_static/window_1.png create mode 100644 docs/source/_static/window_2.png diff --git a/docs/source/_static/largeWindowSize.png b/docs/source/_static/largeWindowSize.png new file mode 100644 index 0000000000000000000000000000000000000000..b729188a48a83bdc29e63c6aecd6c366cdb0fe36 GIT binary patch literal 21211 zcmd43WmMH|_cgjzluZdpN`oRG-CY|L5Gg6?kdiKuMqw*mDqVsiC?z0Wq97oh(%s!1 zXL0|>`{A5%KAdsRhx6|7+|T3V4SWCgb**cyx#pbf3RYE?!^5V)Mj#M)3i7f~5Qr=9 z;U7)pRrs6C&X_d#A1p_C9Tx=R=I_gYt~5Dhn;{T%2nAVb4fo{LF-v`7IZDK)aEL3$ zXYD0Ax{t5uV(C88(FO7sUiVVCYt2{qF_^Hs0c9F&8mz#Vk>RMJCPl1zCxiI9+Gpk` z#L}PRX7w?O(dNy+sjVz@tPn+*^bvnGBGgzlsQ= zY1T&|y00bRAm}{lT&^Hw@y$38uNGgoT}KdzM^zvY8JIj|2&rAE{~v!ifZIhWMI;85 zuF6bINl97%(D`J4?ZwJKo?cmgc6N4WlDn6eIy3R@+X+{2)T5frEv&5M(^Xg3){fj{ zEyO(P%IwE}`FTm6AKodrx`Kc17hKIizlb=@D=ife7QS8QySv!^NiH%sFYh`@+wVYp zzw;wYbW~hi+?zLz2)kcI{vsR*TfEA)7gN8ki=-%Xq)Pfp+`TLNYHN2Qk>4g;<6C+8 zA)RpkNAC|C+UN;l?q(rT(K~29{>v93>d&7)Y$Q}vR0OEX%F2?G5|@RJ{{DVVb@iQt z1Hb*%AV5zI{b`XImNWtG zjXMkszu@-`3=AGV{L|Ox+W$?H)3AE7C^#;T3?;9tTN&51=5snKV=kN@9E^o(?&#p) z;OJ;*kk!(1gD=_?L45}~JzC)cf4jZCO-f2ycYe&elbR;sbG($5mKY!3+}b)aGC~Va zk&_eJ`%=*R(9t_+b=CIe%a@FdjB;{KV^#3SmqZxcmK{`7CeF@06)kZ_zQH)OlD=(4 zf@EZ5=%}zTTnSSzud2_V@1o@8q zdF-vUSKgqou(0Y$#KgqnwS`4Ri7@I*kA62ad@d}E?!Cu$@oC&^)bLMdu3%GRf})m| z*5g&l@T~Lmb7Jf_6|=Bt?(h5hG~k(HRegMXgwHI0CMYT?*)4XZwN8j;Uw;L(7D?bmw#7Fu!|NYDG@~MG=5<$aP^{h=(eSQ7TgOQ}p z@1H-*YHP=Nut#8Wn{%vB{0qF%GQGRhn~kZi-uw3L4gIMAo_~S9|MQJ$eGjkrjY3fW zZEI^N_?M^Gq>p*=fC1gs*7oc7?}OE$PkDLaidKBO8!Nd6Ritt7WM{CzmYu2nU0qU0 z1$lX5V&X{=h=kuS-o1N=^j}xh9UY~j z|D2W8WXwg5d*fWZZ(!h093}aA=x0@Cb93|ZSqSI)iLP3o6HlE$v7EA^B8Hc~Gk@^( zEswV6%F&F}8k(By29>T$J(-1th2>X|l8#zA>%Nbn4>xFyI3q`r4w^F#5>}3O7BHk; z-Q1?d{0Z)JayAvR+`rF&wzaiI`jkm{ zcY?I8%Y;W4^dOXU3R@*jf)P0lIm&&jiHNJ;cZyXHb);`TUuNkEHZEX zqLjp8Slzs@6bc|2A#~W$|aO_u^R8r~C7M-c#8;?6v#VP6GyT?RF z*FVvLGj;0cpY(?SwYIialdG<;Kic2_h9HsG@}|cgpVPGLX!)P7_~_W!fnXvg-_xz8 zfq?;~B!Q#D!(77}iUI~?hTz6lKm@GTVftewrLB$X&!0bk`*t7I+|Yn=q4NISyC-UD z^b`k96t3>tm^c+s}NlH$@`5GP;5fU2w{rk?{yJ)dE zOI9c!mK>Uz{jd+yqr(duHm`6q+V=h#`Aw1G-k9lwGp?M%Ah{kCgpB=r>(<5ii#gYY zH;O4_oQ9KRvm_W_K79(f42=ci*>st}A3uIbobC3~`kivKumoqu%+^ROme+Vh(ms0h zC?r!>S2xkF{qNsLrwo#k7aJEg*48K_H+k5fKM$1ye_rG$9Oh|iY7$~_L*jh#;ssh2 zg}R!e+|)jGco58fN?Qws^2?Vm-@mKE(OalJIoMcOSO8FvabV8Dkw)*4_xIFb@~-l7 z`$2yi-JLskpuTLa`1||UcpWGw^0l}0GF>r_gZpL7qNKRE_-RIEWnu*t=X70bDh-qtx3%Gp z4rM4NtlIf}0s^VxUa|O1B*^VLjM^v@X~XCH8tUr5tL1C?vEMLoa71+8>*?)%|Ni|% zjaMMSw6pK)i{C^TPSXuoA}Q;Oi&j=v+26kDYHE^DQic*dQB@`9wRk)+{;{aYx%1V^ z%1Xk@?)>FWLJfgP$jr<%F)<-xQf{ay`SF9eEMl*(zn>q9kD$Xi9oj!2AnS1gDt~$B zfM4o{;(Vp=nMkXVSaD^gNRiilmH5QOX``9uX8COtr?_~{w{O_=X3w9uJfZzp@nVHk z0&le1qZp7waQ1((?T8!vbIR9!vV+>E40 zCnjp8&y!bCX_{;EGdyErXAk!q z*xuQJ{g>w;K4GAS5hI%=?fF zB`+;)0${SSu~Fq5e{bW2f}q-VolGk)FAp0V8%fX3&VD1HzB)uXYv!%l_3PJ1@d8&( zbPjE$|Wtc>i; zy!-G0Hfr_gODu=M-~E}GsHnyNvU#Jhw!S`<{iD?()+g`>zbZgihi{1sm|0mNR%j=J z)HF2sxVSdAwiJ|<7*9wU(2#A(sHk$&)9G1Ra|#Ly($k|qe3)HchJ@wt?Aaw^O%imN z#(52)>HXz*k-|j82kJuRSdOLOz<7#L8inVFfxDEOBr>jSj&_3KlHg>DNw&q7*E zPEOvu=XJ1tHWMis&l**e4G4L&k&tW_D>OBgrnX7Nt*>WbV0~e4Z@E85{mBz=A0I%? zf%vqwri~$~WLh;xwa28NtNdR>I zeqc!+Zu}{S;2}G7mxtG*N6RZKqN1WRyqueb{%)6D(rw}c(klqs+y9&NP*~EKKfe_h z7Z(<)L9x`)(djBgAkZJaC%b5BXxNTb_Cwy%)zyWKhvT5DI}F{!&U`x{9{-4&2s(Xc z3~8jujSuhN^V*M=!|6FXKGuEu6zT>6Mt4`&CM<13LqnOv#Fxy>q2b~E9cClHv%U5t zL62Kp2n7AzJ9k``dKmHW@Xl#0pN$ znh#d3-0y{jOAEdT#2{2`Hvm)7cjUG&x=`I6dk`-Zz9)H>!>CKR3V30-von$jv*`PG za&b>S6}RgM;}gGFNpwa^E5tg0+vEW#Ziz`rJG?ZAS5ZVEF8Kg8pnJ;6&9$9xONfn) z{rmSXG4}N`mu{;c@$vDH$dDor5w8dcL)!SC{Rt~CFNd7WsZ)fmNJgcXS5!bo@bL1& zxi0`eb1S6n4Z-%-<;&w_-ww*`ix>PTc^MfQIXSrp4>Au#TUuI%YP;5R!u zI>eA6X3>}7!nuwh?A<98=fu;us2rF4JNP0 z{%L9|D=po=r-hI;($mv>`g9Q91uFV=>Q+pmkhTX|y?%#Nfj%eu&`h>8H$&@lkB{%e zDRsHy6w27x*um2Qms-RXzI5^cAiJl;etHOUI%tUwjhgj+h)GFH;n)?_9{+7@oP!P; z^CnexUf$VS*#rhP8G@FV#|29ys+J(6?Zx(NOE{$Z(aA=gN~+iqgk8BK z6cauQc1FgzCR#sYTlZg&rKP6;lF?Rby1SPrCJswAT$7bIH_r?RxTep(ujuILXlG{! zDR&1?*T`rBs#4aMFEpZVk6#sH@fsN!UAuNoTJDPM6ZpRI+|OkBT(BDZ83fFl2>>m{ zLR%TE@cy1sBA)^sJyu;%P*Ca{R}ljgDyUY64vqA8-&>AJNVxq5_1xB0LqmhuRhIVQ z(~b&6XyLOa^cQq! zS65frt$hZ4_3vuBx}K+pTdxOql7(G>n(yq&$VuDZeGK@38&%){p`%l4xF8hx(naoAJ2Vo5J4e;pJ9{H00jk-U3~6B$MFI4 z;YL#wv}szZsz0wSOjNiqF*D0Qe0YzSw>U3v4FdK(lZr)EOj(&=f@)iPdsS5xAqsLU zg=lxwc1YkL8 z&-V4||IpFXZ?!O`K@aNX=~?TsWAO`(1xW$`6o8hM)z#hIU9&2Z5$V^~*4CR7%nC+E zv~>V+ZxIoxYil!U198Gj9+sF$;p1~Afq{^{?lKSH=a#DwEpA5zIxZ_~0n)O=SS6(P z%+H_sd`}Mn6125FhI9hSzclH4jsMC}l$y47pwXn5rkOW2vOn4$CLuw)zy^g~lRsc6QF!uh>r4wpLbF-rL&)GR3{Hs_F#R{jek@B?XO^ zH8Z=bb2Qx;Dj*;Lr&SCWflP;{?$j0AMf4wZ)XgVcsGB@-n~=)8`ul4 zhDsl;e6b=!)C8Y1IyySvp7io?(_~b6qNm4SdueHj-Kf?Zf)apo9Xu_3;)2%G4?sS+>)3BjQYaSF)BLRghu;QoawV?3vCHI zy(*GZh6ZwxG>qERV(yNRx}Z}_6n3#NG&Hoe&8@4G?7W8f^#$<#gkE1?AN0*@>+7`u zd7z1$o|)MSr>18j=55>#^!GEB4B%45 zfR?rn&Ji@i`xcVWh5&_fyPCK0D&i0;%`y7GW%K$o;04UgK9`pZg+E*|pT9*{u8U#Z zMPDUr45t=|4d}<<_pV&I(%s$t%a7$o0?sSxtjbCXL1z>Afu=}Wd?Zo|32}*cn+HSE zhrSKST&~4x@1u7|$%=YS?= z379P)Lg)K$worz~M?^$Ggw+Ow5oA31su;^wSX%lJsRJRTtGm5iF4l0K9IBiJ<&WL4 zdJu|Na}+(qpv+s`?tlN9?qI`#+7^C45>eqJuM(@3a7L*F(IU;UYnfkD&GI~28R$8Nh2=extkpt zs`&8X4d2HYxB^@ud=suOUozdjYh`T>bg64!w%XYQeTp(CJ3COlYZv(#LHsUf`y&o* zE%EF?Ti%h2_z)8l94vjUjsRE{V3#0;dkc|izv}3>p0M=TFIt zQlx|I=Qn`aEk^1rF&Q&VUD05o;M)55Ft zvkMDxA3l6YN;(;`Oq;sQQgDQbu`??xj|U5%)m@y68uCMaE8_o_nQ0E8Ia21(wDfyq zq$)FW4&a2Pec}VIkRMS1@d(MtfD2q+UY^#R_uQTh4h_XoabuLC;??%_JO&Em^z1Ar zC&$Ib1(*uJ!DQ^ZyZzcmTf7plnZ4BArpDjA9xM(?{(CpHw(Te2T}HI3i^~qw^L5Br zy1IXppSDORZ;Vy_8yZr$ISMI9sWc3jT3{zCs;b`q#s)kz)euYs>*ML^Ih)@sot&4O zyAG^0AT@YsOA8B6FE8lj(iq$Ka1r!o_Vy8-?*=0neD+s$8cc|nfK23$WH^();+<>7 zM^QT{C@2U5gbo1Giz-c05)yXZA3Bh7;r(P*;XWuJG5h^-5Lm+ejW&ZOgBF!Gx z0#fb^7Z*4h`cU?u7@C?gBwXd%zE|+&i-n#ZrLZF%Y7r{s;dN!V0;9Tn=dFMKyq$Br z+-+cnBWWe?F@^F73T}*CpGgsYnQv4VvGIDcQwLCO+-vNsO>KKnMPfdDaCdhX^E*GS z+NhO5hEwq0ztoiG^WgNnGg5GOKZG{=tA;gzKBy~kasL4RKo)TKRV93vk}}rcU*db_ zc}iPVQ*#C<$krx;g8z}eesZ;nHYh}s_18{^hlh26|EmbU6uoF@XvAM${UnbQ4DdAP z+qb_hEmIF*C2Gy{p%;cze9AlxA1vWN@6j-4-90c+nwtx7t*fhxAkFM88a+5Pgy)J` z3W}C0o}T{jftQz;qvK{*x{PIeVrD^sk+$|rXs#jar}~h2m_igqM@K{9 z0ca`ewhDX?HxCa3l7WF?dUNVGAROwT0AKIf*;$l~%*^z(@6j9?2}uk*o3^H=4@TKf z%q%FXMXfZK{ciwW5Cn?1>WSy$$8F;^UW6!(`015=ozU9i8s4ZQ~?k6AOUIF>&MmJgBPw|Tz+e!7smjk}(`}5_vX!;n$Z@hHXsSM zG)+Q$LQ~(wEz4ZSbOMmI<;nJRBaQ^_4Z3^x-lolIX=_`0jikN@qSdM5=$A1UsKV|9 zY0N)`HoeIHmlvQW==GxMh|wh-2Q5WS-EU?_#uDebN6uS2osxn)JkvTsR~xzUKqK0N ztivlHu)nw0$OvE@3h@b$Tt_4$n8n|}(^aYOx~`837unzFe$$ zoWH1}ML(Mtah#NLaw<$qOM|YlwA2mQ4M10)72fY}Omuc0BFK{^QrB^=1w5O77Z=ym zc?{Iv6lT|-u-m<9-klEswE{dk5IH-AT7f4huUkbK<6Meb9DS*Vs$4_R!@^{~80Yah% z8d)ehkze?I=p5Vhq4t05wsNP^kZ7O>paBZ>ypzNg@*k-ZK0xj^r?Fg@C2nea0Vk!a zOI0k9*YEJ;LXg}3ZEeND#)dloM@;HdQBe^>=9Vv20V!Z98Y(I= z>po_X+n_klckZqf)Xgi}B5<(*X#jEYOq4-j5|$bgOBpBwc6MtCyp~V3z<=>7(28%k zY{Hjyky#eEsZCQ&t-ezTmK>fFNOvZs<<>COfT5En<@u3F>9&z*a0M2 zYV(i7d6W`?AafP*0WGk-y*>FpbQsQn?}3;&4O^c1$?ZZyN(!VxN+$H|31l3&%|G@K z-rn}()gAyfICc;~H$w-#!}~e%MpIj6PR_|<_nla*2GE3>#omh|Ug1!Lms)i*EN*cT z2wCJ;xGeZyoE^Yo15ARW;|D@^reYkx&C(YuTE2fUUj;$xx_9rMu&_?oYwu+E6(B8Y zfewKm1dRep2JQ_R6v`BYTj+@AExi#z+2Cb>Mu+^L>njK-_z7T@$A3Im2aExg9h$6^ z6l(qZSGyFu#!RG~Rytu*2uVohmX<0^8?gbfH8(>{KdOCjH^Vt{|mw1c$O8!0vhws69%BN4CK3+Sra;nzVuk)>e(hud3gqWBB z1r8lfquOL~uYJJ@@At95{~@msJOt=6S9IBlfcv2BPA+16I=*5ZlFV!~A&3%1pavd; z=A6uLLuz#Onv{Ergby^u{g8N*@AF04jng7`PVnd=F6{|<>FKXCzXO+KVBqsg=Bm`- zgNF~#j~3GOeH-OAsM=L6HhDkSzkMcpiA+juhnxYJiljskKp)}ci-kl^K66JP zQ{X@Y%!CZ?^mV6nZ|!{|lQ+(Df@$}Ty|I@QkT%*Ac;y~HeypikQd?UKA{LkkpdOuu z;Ui4mgQ3Is)P3~4RYAt)_snwBf4>=D(Ez#&&~?B*o}V2|Kws7cotmpFm9X<&$$)E8 z$nO8T6Alujzm=^6+c%bJJ^6qxulaHTe*T!)*vn-tDdG3KdQ~d^g{v#T7Rbd;Uwiw; z@7`hQ9T*zu8vb|}XGRJaX)mr;U|0hHAL=1!7Xg>rmyqisK^?3exj~)p7^tY~>hBd( z%JLjEXEoaR+oyimOhR~oX#!}D6ahO5;ebDv?ZWYL4rI9B)J zwZM^4Nt=OOVaTQi2Gkr!Q-QR@BP00KBA_vEzsAY_U$i{ceA2aRO!w|t9BxiQthsMN z(E|qf9&);d(h%Se?D6Od-w%G~miq}diDa5G^Aya`V0d^`WMwfarY9whfRRCjzzOgq zm8csV14Bl2HPyuxsER<5w8Iucqys{`=A_DH<#RCh65t6CX$_%}rSiXiO%Qpp>?OFn zxydCUFmITZULvjVu;oeXcI1fPCY{#Ln@w%zCMHe?>jtreMexKECnbB*%E}$U?}C;# zzp`RuVv?Sbfg+HVk@?-5r2-p9PD7I#8~gBF>}r<^AZ!5nz}|s|2O1%go+8{0;#FH) zJ9U*dpY-U}vw2-$%YeFJynA=ksu(Ifgc!sKh_6!~1i<_OFq#IY!b?J#5Qu8Wsb8Sr z{Y2Wm($4tw31}M^;0YiEFKbwJb@i7oe;OO#XrDA`hu&Mh$GL3i+9Mh$k2}?$dL{VT zJT@+_t+n+q^azkG2vCqwfvba$FoL|+8Q{J-Sr3@+&przmFt`0V+CXq_oqja#%ILO& zL;)6y^lmHI=*We;1lW~B?9YzZR@ZqAz^55<)3gK0KGS_8Lt#TBAuW59SHSK<39iW``CCtrF1uj z$MIrrhYKLKI8Q(m)AmqY4U>zu#aR(x*9f|7U1{<2D-Gfrd0 zH4|R}z5#GV&1`HK85pF1CY79QYh_h#)tvzni1lQK8nESBTBXp{kIcVZAH~AOo&8Y? z>Mvxl$mlC-KvD9WHXwl_<9Gptq@e4f>gjM}qd7=bVsxrS#!#Y+}Y|I_dCU_uiOyrm41g_-$BNy!#xCH^HN3%)gF8z=xbGNNxH zBcVqHYr%M}PgUF=2uR>wai{q+5?l7S=r5AqgXKt0l!hKXy*;gLmOOd%`UY=ALTgLQ z63Aq*a^Pktr~e3S1>`62U_rutHBR(y$*no(!Mlhe7)FX(T*1vzTad3d_*m|HPFCPTt%Ga+fodHoXF-j^boqzwn1N%>ZzquZm zNGI3fguo|X-8lX?IKa%r$i#84Lq9t|d%SBHakk_E+*yjqi~C$$1d?rxckb|d?pisM z?08=RBhBg2j>dgWtys)5MQB=q9EKNYDwhD!7D|Lk{N410Oa_#E7{izRY{TBCp9(TK zqp~F=a(x!Tln7#ZGZ-j=%q*n@G6lH8hVgL;z)Q^psb{Z(LPI+fcmuV)t_4JW=W})< z$JCU=O00Ab-b=OtYzilP0QByMYtR07oh-shxe!$q|+Ey1}pj%nFDWv%(ti->RyhAPQz3ccw`K<4f>XI8!*) zg=Iy9Ut>!$HIk%>)DtNCPGIMtQ&_O%Lm1bcZYw@}b|;|%QYZj$z;S=R)&dU-Ec+n| zwV>@t1p#XKk)v01-1trdKjQS?;GjE*oZQ@n+69Kst*q$K08zmxR^G^D3F5!I? zb+P50>n?DQ5K;XuK3)h4JLnxi5};LqdhuHLKuR;JyEv5``Vu(k^ni_9PrhnUguf*S ziHuC+GP!ynocRV*ScnYAYGInaSzoFg3ch}gXMaW=tQ=pT` zj6cCeA_swkynLYh$-0Y#t`Fx}A`VCaxmLmX2lxeb#23`7#S0gGMFj=C#m9xO8bgoC zD*slRemFP$Ct2WS&(-#diGEKSe2RK{NuXosY#&Vc0daPa+j4qXla@9SLc)5(v?tcr zdc(TTz&S~ptAS-Y|6YEZ${puQUD=B|W2$uVjD>HO1!*z=V(y?_V_vZH=>$Fl&+oL| zgHEPEa*R90LdC^Z@BNvT-zsDGP$gZZ)spb;KMc_){uCrkDg6EpwOfzq1YcdZR?qx) z?3?heb|<`eLX4@(CVo?o2BG&(XQMG^`1=R`8@F?|d@I|=2diE>*xPg1JINuT4Ld$O z1P8`9OC?%bT4!YyJiKtwBZi7CcR0Joh2Oc&E{1=4`|WL~q2$PPvH8%UpJ<{}tN!2B zq)Mc}MDkFWp*Dv2o|2+*OA%@e|_=AnKngnf@>n#0ceZ^zeihZ#LSXG{} zmNDi!_Qeea25wdQtmS{6nDh_9oBn0)FlI4w_3Wn0H`V}u}e-B0r? zt>;Y24@1pwmDle-bRiKo=gPJ#vDZwVBpIF+Q zqsa2Px3{;pHkq=SSh9LzX9le-uS$BeckFKIUK-)zkm*Q==4s`j!HQ_pRD^r?_$NZ@wAgzw{_3?>| z8_3`2Sx%adm>*#*tI56aA%31X_JcJt-?dAW;NQFlIx6B;lKpPnhS5#(y)-qBtE<-> zmFul4F7y?jtYvrOpNr>&w1+Yla1){+w{T9>vU#chDe{|FRI`95n_#We?pKY`k6*Q1 zVYFxdjnb42JSq4EvAVs%71U^}N5FJM}3-C>#|DA&q-rWUls zhDHBjhe1yN@#v$IiKDZI#s(idpPB7*Ub6cE#tn0gg{!phqdz#6IbIx3{}ZSbu*SA# zJ0bI>;{69i0MN_8Hm!P2T6za)Y079S*tJ0XA?b0HLeKRVOI&~SmG!OUEJCjE`F*;2 zGv-kZh(r@ywHrkNwz1Z?h$b>C5sL zQ)2Gq3P*w2*|%x|&)$;(}%x(Hsj~zn(>!Z0M@{2!$Po zzKfpJ`pm?y@@pPl*oXfe|NEFpl0mA$#FLBQBPRWu-?B0U=N4Fa-to8>7Uo5t#wvgz z&RQbkJkWWO{KDqV8?1o0Px)b^OB*C=Q+du-{$)ZWtgDc5Ym7Rb@f&6?E7#RAui-DF_ zm&2|RO>$WBCia0J1zpIuP{*>>0}CmCx_zS&$#GY2PG0CA{{d6aVNls%uKOIYIP@(< zOH9tJ&f0!OBA~Y5S2X6=~HbnI?2zu+J64-31}r8k^swfev!R{^XrT- zhLOn_vzYQ~7Iasj#GU%&Qs}6$m*GFOKQN`I;wzMsg{Ed_H4F_UEZWEwS}u~?JHRA0 zd5yB~7R|kKo$?k15UO01b|7u@ARkVo8hS&vnO{uECR;P_7VgF|ARYJi-1+mK67pHy z%g&l=#dyePT%+|`|6*fho+VXexMWy{9WO+NSgVZPt}B*I$f?e+{+9l?vH6W6wWf!r zsCZRh(^rW*Lt!YwBr-4C`oafUWYp$VBxFR99l`5E*%%n1&*q_5hPL@qyYAF%8=L(p z^>MB%KI!#b<}9#2P}`69n_bnE2y4x_H252++2SdA&Rs_JhnIf#J}P?jLx0$dmrR-f zT40diH}DX3)OrRZFUSV$cExHCTR{hZoHL>Hi}C8j|owuqEDj?7_O~egB7}s5pa@P zTvYV-CbhMN#r`*2lx)Ms=QOgwZNLKYlQSa)|_ecxW?Diwdr_TzYvJ0tr`v;ObXi8Cu*$7O|1 zZ++#+cU&vxcmDagjoe}EzU}J$@Q?nY-CD{KTQM~6HKH3V0-OVv>C){W)w8z!-S9iX zc85RY;nK*Vr6F$QLw8U2&Vp-5D&WEaE;|hW`1G;?**VtVpZ1~H_!}a>a(^E@HT~ekVm9Q?;n*Hat&%F<) zO~yV|Hv7w?qr5+PKQ(-6smX+r*EZBvIiCEnMUCIM?y=1$DG3;H>>XY>i)@5l8 zRF|ZIh!~qVz;DO(b>SjR^`)dIGMX~6BX)<}^JCg>BP&BL6CNsA4k?DhPUf7f>T6%m z1J6>zlUv^PW%NCF>unaL*YH|C%H0u)zLNfn+l5-l5!lKzAZLL4Fwwc55W0Z$|45TA za`WW}g1AHP)dg|AX}cG8F09U?i_~Tl@@!is)0H?Yj(%5_Nh>%Qj8wQ%2$Co%J<|BG zE-kjCEe~sg85C#DXYXvtxv5v`vE;o^x_F-FKxm^7t1|G>h7OH8hQ}#hzB^tf&~Bet z62b4w&dx%;O4QcV>Gl5LOR8Sp-cv8Jv%}(lo#R!0q{sJ<*=86Vxu3lJ+$m2RtMaV) zz+!u^$4QYLYN;e0`=t2mFzkB-zr>0r;SlNBl}#I3POA{j>`d!mU-QEe`{0>6n%Py8phv%mn zo=7)-Mkkx_?;7Qw*{^72{2F}jI4L}IdRxzRE!MuvCkk3-n2u6bFMP80wTkFv!GXTf zKmE7^p3bqJnzX=cA1|Kr{u?f_f#VO9nSkx&U~mF{0vOxCOVrZZdf@$MZASgHvixgJ z@QFG#GJWdzTN}LoN_Ee`WTj6F2o|5;XB_lH{Cu!gvxM)xJVtK5y3*)bGT7HgOi1|p za@+{K{C+1!4xq_^^4uBl6j*=Ywptp&Q_RB3niL-o{w4}?a{bEPa|`3>0T)|g<)@w` z4fBor7hgYsEo^qyOj0r;Zy3tFdhVCIVviTi@l_Cqubzp zpMkF_n;BNWs65d=?savr+3=;giAXiB(QWJK7%#CQ^QDII0`{{s#Q@-%!LE*HB?Zng zomy`XcXy#nR}x6dVB2qQ60v@uDbo*f1yTFG{VzPi2mWSuUgYREU%X46NuTjAG6#P4+3yjcNk|US-28kFHnv$`)$7l%M-tNX z+Gg$X$Rlo0*vtd*B+0@aYA*%(8hi=K12-@* zghWMMbXyrd2?dNRsTejnIf(5_2QZ*jWwey^-w?bbi0jhs#>c~}0i^=yE<9XZ0u%@- zASbJ-sa+2GoI^hjK7(I#6;>dt+pQg_wEFrUc{uaDL|~}U)igh)Lj$h{q(2ae5+2*L zuo+;BA;73N<^s0`C$w$Q0`FfigOm$-X32c6fT7k@l)CV{6LiJjzaP%U8EsC3;JB&k z=Uxa&_UF%6^~OjJsZDOmRdKaX85y-l^OT^x0V511AwhnA(82KOcY*x`Ivr+wfi>P1 z{ybRyG}TpzTonpD2odS&>FzhQ(5m1D1U4-B{d=%@QleEsHj$DlSl7Kurz<2;)~v!7 zkO?!OV%YJisfHkmg6w@LmcvY=r+gS_77`Hwx;k)@%iyn@sP^dX?p8B2T>tNt8Wpt( z8d|T_kF%2#30yt|N|Cvv;%N}@RyujG7;thAC2u=FwhxoXZ9#iQJyKABuxw~*0>dQC z2tWv_e$^2ByawwH&d%%9_Yl=gVF`+p(6i!PWPWlOhf4x(y|}y=z!b&|+}uh*{Xqpi z%KQU1xrTcBEipKk zQd3garlwvKfGPkXjg5!r;~>Y5kR?8)yZBU?w}slj?oVegUNz^iAn7Q>lbMw@GBmW8 z{BaY^OQ0Z`Sylpx4XOag$r131u+0%htSl_x(s*cSNW;W~CV+0s=R{y+iWYH*l$t-5 zv$0$WSimR8kTHDZTSD1%`{7A2T@dV+9VDH-j5Xj`*+7LyXu8q=7A|Ha=m+Fa)v&tmi+3L^jXCXc`dD2Q0Ol;G4Qfi z!XMMpXbnI2E`iPijJ7vE!7F^O!4nre8j#?Pyn&t88UW=;=P$T`l2TL0`})uXzzxR2 z5nCt4M=WCTkQu>b2(+1%6&0XPam6(?G_XDW{u8DofPT&X*Sn11vXY*kPJ?6vV&SL+ z3DM3*dE24)2zbWWB7=jM+BM!HaCM_94gsr=85uKS+BObC-enuBbSI{tquM1mTF85UK)Xb1EqQ zPScVwypQQhfP(J`itDqc2*y@$zf3+MM6A=f6oPZ`xt*PXwszB36wG*ysNsUQR&$V> zm-o|?uZJFw(W+8XQnIpWVBy2V+rw`2HtRHiFlcXOHF)CG0KS8dO!vJYtYFd&h6f zm_|PQ6iy?aNOchMz|wL7Sey2?wx@56fxuQ$R7?_f`NAX0He&_}s*oKMQB7vXAwZ7% z5KKfcC*IrB1J-^|EqNuSlG0K`xb{HNy2^9!8<;AKig@Z6t{X@Bw>AEO8~`f-Lm==P zx@jBf6#|(K4%mz|K|<>hISV3y7~ZuRLw5X%*&kyr*fzMS0g6|8x(PT|I5xn}4MW)L z69@#kYE;D_UEm_D5#+o!OHE`tG~(ctZ-gv$e4) zE-RBm!jJ>`?b~o%c(uWnV&=!1T!HkLa{P)!&`K8s`CY0FLXID+szkwpHZhS_Jt3F* zJGg>r%XNo9XmJnzZNzRS>A30w`RW&FqtlIai5`n@r-hAD*hYw>9AUgTe-GdIx zW`M288yvH2>h8AV)wk24O~B1&pXbNhbzDZ0e%xNQtX}I0bn_UaGN#0_3KM%sg{~rw8t=g zdQVpu9SaK!6BE;-)S!*MJtRRI5!dXG*Gvj(Y7CT>BdGkpGI0ca#r+}up#pG_GsvOG z$DXm<@J&Hg!m_gl;|J_4=s~2ebgNKmoWTkV@}U1sbPWg%3XdKE$&VBv*oOnW=voO! z;PSmeld7spYVGWSSOvFAM^n)GgZdg~OkSud{{C0tQKd0|LB@~)pCveh!GrGw7I_HV ziY~YMv*nzEu<-Bze}4@4d+@z^fkOiNs>`2(QUcDEZJ0@ee@EHX@&EL;le?f+)6-Xr zJHL?>10*0VS05b}$OJ3XW|Yqrh$*h7W?%q=t-mp-ugjrm48bA?DrlF)>#osJHu$L@ z_)H~bW#G8QS^KTvDpHe~`9O$P+5f-GRvb%^{~xYg!Ch{90rv^O20`9|NhDzYu|=rR z1R~yt-$BWTr-0i`VE$s!Rixrkc81d9$6*l>7950cHz65(0dv5&Z=kz)FLu$VV@E1y zT^!Ctg4qsk2mu>nt?SaeO*CM;Ta=WoPnsF;zV?ep(#zs%L6QK^HQYb~jzX|pLS=zv zC9su;c0v}J2g5=V629Pn0^dHQbE02X(9W8fn}hbH5{r~^hkFe)wY3lS_u>6}Kwu+4 zK{+Ey-GmWru*>ffheY!a*}W-y$Cq>pWDWV~x~$c5!k{#iQ|WQWT{ z2KF1n57E)E4#7b|1jh1UlKI+yxd?LJIH`t~7 z=%~z0Mjg}V&vgt9$A^YkvZFvi16SHxKSUuo|1Ztd&!1zd`~YX>rSb_ruXs!flDC`S zF3~YE+E`qa&*}vqn_-vfIxenkK20tRGnGi=H|I2)zf(b>iV#5nA z3E_!z!BUWyc+6+Ts|{9cRM3&2>VpSw=z^diO1}a7jk&GuKhsPAk1c^`Yx;ZJ$&Ys8p><`R4 z%J41>0mV>q$I(D4W7TdeBCgi z)C00~83!-X+fA6+B}84$4MWBObrSje9gk{Q9M8kna<;K}&-cEMS} zq?}BUx*6|;rQn`H(lfP%35MqzsBAD%v4I*I_3oYNIX7VtfH1JrL;d<6hmt-sZujN2 zKrn0w6--1os09G`Q=$B&Z*?1teH!ROub;|==G zFu*5J*P&pmF#~0LDNUxqMGB~Ncse*=a3czm9?)Zv8F-D5>EI(bL2|OP;?VPfSFxy} zzCIm#gdaaN$bUVnXS_QV>=tFg0lgWho1oT`ExCH4P`}{Qu9M$NQ8t1e5gf?iEBWPI zAV;2IP-3l#O$4$iJUt9ofXfD3#vPJ2@YHa(1BDLc`7a+o8iSV`cPb$%=?>a{tnxuD z40tKR+%XK^{Lg37hr|~6aG@1I{DV7`z|8?hV89zNw5zYHd;h2V6Lob$)P(m=`^E)W zK<=TtMMOn;4;F5{-Rww{8`ui&AU3j{Rg|N_c~n@R1a%rpt*f&$1(Y!Wqfo+(1L{*EJ->bZO8Co4hlh-w zY6?akg@lEn=>{}>c7C#fA~6KBJ?s{s8E{C$?I^I3(B+`-R;MO}ddXkCfb{U{2xh`F zETc*SKv*1i<$;SH(##D8n$ly^y9B;b6;iY?>kS=0QY7SnAec^? zRvsyWp!)`%a8FVgvckfmKmKG&dG`?cQ<%&chGhnqdb4K!L0yOJ4|v3#TR;G!4hB8w z(U&9z4mVtl0mI?_y}e)`d7_~3944E=M(3^xtM@mCbq~fHcXzc449CXDm1%-tWN)|x zj3kfL)Vha;ge4@-1Fjm!rloDn&1FJD`u0u4=Xf_H1Y18MfQ^~?qEv~?QGXJM> zrum@-h=M^YcTEe6FEH+Nc^i{G+{Fb~8vMT>Z%Cu`lN^7x~(>S3J-5gI(~axsyMA<zebcxKPX4V?C3leq1SX8IgHd>D8{&dHQdFJF3!xb`1kyy_KjW!CTC zyHz~nCuR za#POcn-%kbJ%Imre5~ev`&k1#q6Ii?RHZuAD^TR=*Q&Gofdxu{$X8%n`BB?w>Y?4JN4o8(JdWM}h0SPgw#N zY5=F34<<}VlvsSCFa|iNsUv2r1&nUs?%mM8?khUwzr5uABLh4l2pCo$D{6q7))r}O zzwLXuL}?Mu5&e(JR}2-H*5d0w>h?%mkUH+49g3?Bj~Re`e>m6bD>Wj>DUNxcco8Lh3X zk<;G4e_uHdcs^062yoNd>RV5nq*k2V$MB~H*kTrORh{hl_sZjs20WjC{mSzBv0?iP zjV{A2J9fM%+x_)%jNbF_zu%Ts1Dl%n-`iVQT=*__`f1U^fCyj{>+RdzygV_MgEyM~ zq%luT+IZu^g9PB};+r{gzy|HUed}^IeG2ArRuR&ietKz;XP`*mV<~BA;DHBBjR*GY zO8-$W*%|Ziej+Eshx`xoxEW3~KVf3{q_C5r!Nx<6L4kh~O@+?u&)SfAM)-T14)Baf N22WQ%mvv4FO#tNX2e$wK literal 0 HcmV?d00001 diff --git a/docs/source/_static/no_overlap.png b/docs/source/_static/no_overlap.png new file mode 100644 index 0000000000000000000000000000000000000000..081dbe055fd8597dbc3c2b8eec843974a495cc68 GIT binary patch literal 58942 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 z¬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 z

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 z z1sl#XZ)j*}JwKT0O%XC{4Gg$AJHE{Zg}^W&vxK_{to_fuy+p?YfSXm2Pp~0}E-?y` zl=spD&-eAb&|)cJ-SM{a>0(Y}B?e!gTX;Li(uv0eG`@VyrHF`U&nX2Gm93WV{#r-Z zZizt^kdePW?_mQY|K~k$qVPR-qg%ur$B0dwP7b$GEmYw!)JxyEgoNP5CmI?qz>^gf z6~W=~A5FGqW@dY4XFtCLyUaGpiDx-Y)moF1{2IP{jEUN#?&#}I;1UrQj*A`}8Zuv$ zxO?{#kOd2gft2;Az;UjnnH~nErlEtnS{rqN$A8r%ylrzhmnx{ zIx&H@M*!tZ){?n11|KIjNbWPR?W9^OFJFQdC4sy*)C6-E5fKp=M=mZ}`4+z?P;$Gk z)&fBjxxtLgB#B>?eV7wzAQ(v{8Gy(UhFP z&VW9&j8;Lub~(u=0oEO;wC;^Ffr7mk+6gN3A$|wX{kbS`uR}@t>T^xDvPs?D-EzKT zKn4|RJ*xz+aU3hrWLtmFwLU?yr-${j;Ry;uyhQ^_#G{$z&btEwdA^J?%i>OC+FHVr zg~iQBYxMxN32%60q~bb>%2Gv=fk25@dALyM@_ak!@9RKv1#y#J`L9`51>3_fzs&x5 zQ2_!i$@T1>?63dM0FZ%l*HtMymIF2z7PpJI+|9iFDfu1Gz`!6YE6Z7RMo~h-d);Ps zwyVy5M5v~)<5R1AkoTsIv_)c7@l@rntSmbXhzRID@tOf*ywoT>1t0X%@J1~1#0zC_ zZ*Mv)JyYseJvdg&;IQ4`Z!*}D6J!!y&zQ0IK$MFkXJshz{hLPRdA)4xLq>y*t$SV0 z4<<)G`o-?kHEEijy#Rd}0(LCu?C7@8Z4jvD=JCeImXqS_>}+~^=*r4UgIWdyt(WMM z`j=f~#~#`Mdiz`s^B$AW4Zp~xl1>UF8^OdN8X)uG22LIx;S-WjD0Ds?7etbWSfZI0 z0X9asY26e-PDX}1HA&-r_cw;f_k{PbqtW6-JF7nl!>^s5`2pp)may0GbH3`iM&`={ zC?_`FgmhGFrv^5N7Vl4y37RqE2Yr~A4f8gE8q3MODlSe(AbfIMepZR&6u~))bZg)h z1Jz=BJ?^1m+8E=k{zM2@Gp~}rZd_bi3O=xOpbBV^c`ayG#FP~Ta)|Iv zM7OugtEv{q5BypnGb>;*x3S;lTIWX^r8{NBi;ZBSq+`t}MG(8VkxpP4N z%|6M&Ch0dKEYKS1zr_~B9ovw3NKZyK+|umYzUgC_F40(XY-rnLlwL8glE8c(VixT< zHGqGIb`S!%OFh)t5pZL=XSWM<4(S;g%gS9|I*PcgtJ%4Wj=|rdb@sZ68htf1N-nwf z+_2Mdu9q2avK4so9rWR@l@Fg#B^f#S{$9IzO13Gh6QxDQlDA_oazF|3OM7<^y|RjL z63-ZQ2m3z3b=*p9(P-QN$ua|1@>Bu{n_r+*>~y=W20jJW8!r_=fh{NLFIl(eBO#>Q zpPiSJ?@kd8QeTHhy&$FWDhRX_PVB;=ker^LK9WkHl!9$cn1u%(%qtcJ)W_K2Ji zGu=B#Q7H472gyol4#7}=E-G+dl#rG-)zI*l;mJ@Q+TXi z5#=&8HbUhxGC5u3F7A6N2F|`cKwj3F|B1A1@Oz&drkaUnz3KAD#M4qw!8vv4r2pk{ z_>aX6FDQfaY*afDA!sd(^7a+RYNZS(cF)5GeP$AFatxTQ3J%UejWL%P(RJ_Ee!x!k zgkpdd#?6fNhB5xXm$84{G84BSu6~Fx)5dJjePS{|7bD398m6MW;obV^F(;2h27;W@ z-6Tm30I<04v@5Wqh9cmb(duKTo6t$M-raLsrl(HRVjij3rcXdm#ILPmz9{x5njOcb z3i9$+-^%l>GU3Cvmd$h#naWEO0sG&)Hlq_ph!W=R^dEP7Ml|9HpgfSZI*7v|wl-tE z5@h98H^ei4sUB10KI=P|@r^UX;$3Ba|3%*1XBH2nSYE#ttR+1)^h~^jl%!D7>JPBL zyM3LYYv>H5rKPp=QC}28Di+~Vti;{BG0lp;hpRy=M-yD-n0=C|**EHCaki^$wopX5 z(#&pMw@7LM;HHPe7-I?65|^OHFOL|EY8?Wk*X~V$JC$WDx%&5vw?nC@K%;+a&J_}a zbnTSQbgel%1qg2t3Zk~Tb4ofEI&qw%Ag9iAk`8_CQsEaXGIm`fdk@)BL6wW}S*D1J z^R5acV~Z6uM7K-CQT|70VP`a*xT{$sP1Qg(WLw5-V7gM$)EM-H@4p>=+c2u6udfe4 zyscf_%v{=Vl^GE+_}gUkQQ~0(t842;FmhYkm<2_}4SW3210~F*eGk)zPzxmslJXC$ z9&qKUAJUj*vy4`GLq!W3#Y;UT2GK@pElJ$J@3C^%55A56NTn$$9^KQeQw`y2<)M|d zH(VD369`?{EW!~hQHwAU0aUvcuL>fF=qo4i-Np*(nE`bv?;@qo@kme451%v*f<8!J z=etB+)D>8&bz`v4n85S}k`l0?`uvm-{_kuZ9#FHP7X6g$VBcmRJ?s@3D`nvNZnFn( z_1PyA_x>N>p#&@Ih@To57?3<<;wS|cH{@?fGZ@CuD|Ndkd53hz*|vf8dUk0x7|gp~ z>vf5kXAIeD%f9)F&-Ld^t`VVo_gy_O2z18swUH7nWu&ifT*s(tXj`V|(-Wka+N9^> z*2)&u3-07ZGZMwF4foRv-3Jwt{&grfx<3`aW(7e)GWCr;_uZI5zSypR^8weo@yvRS zbrim(fh{|VDjTsvN4*e=3)+8m4GWf~uwP=BfhE~gjf_rz?;fUl+RWbOT-l$b!0;Cv zHQJ2P1eDDM&18p)nc9J#FkPGa%m2M1fSf zf(Ls4u+0n@d)Szl=ZRWkY#~J~-5oo29O1QWJ0)f0909Yzq?zD1Q3$LW+^c8*maPM; z{q*Sp(`~*^;63LVsO`ZShX>Lt6rx%KD;=**E;V!^A&dw-TP}AzJRzl>>~A_fPux({ zg3VStF;xzXXKUAPUThX0e4CSeATl#)mhQDb*?=@$r{BGgw$snekC`6m=^-Q`@ibbK zc1v_eIxlZVN2QCMlhyE_P@T`zq)VuH8#QeB*l|yiqlgPhuad)kN-cz=>*B%6P>%PO zWSe!yYQ#X~j8xpK7SBr@BLq{v6`RumW~yWF`JCT74zl&~T>}3^Xm=IqJ9LlpCb?~n z^yS%ZP66ABvlBat?j9`JIwKx(dNbrf%xEF(3SH4PH#Hg^+GUkGNw&{=9yfdlP`qrQ z4Uht_x;otYxT7fKRyLzf*hQw$&O0J?XZPNGB7~I;Yhor z;`}C2eqrIB*)N)kisRO68-t&Ud0Ysso<5McDRfU@d!?wCJTUb;RMERG*iuL5PN1D^ zD9bNBZo;MT_^Ipcrf{g%>TYC%6f;7U&-#uw!6Mi1Y0$uCD=rUh`qL~EiUJLrD)lli z_+c0KSvS&W=lV@PMKRtETQrF5*sG6JcFJEmZj}2P6aMjB5_tveNNPpao-HgwdPlS`A?At$va*$4UYmKM2TvyZw(DhK-0#Lb%ef|JpB-P<)rwUaBz$cljq4Pga~0MhN3DzMpJ zee_^!p)IO&z_@@*yJE=z*hc}BUksw^33?wvPEIbsUjls)9jWRsu$%W>nD0G$qo(V$ z=zy&=%}771>L(Yj&R?KZ7K!t37V~23?d-Je*_$waJW%b91QkJKl!e|4TLuETdO3Nl zI>D$WkjM>b^!nCpfPD1yh4bLxfQi7CRDecr6;cOLu268Ks_0D0bT1jE_=5|!TsyEL z;=2$X)sYo~qVPLr<&UJrz3GCrYs23W$5zb6@8U47pqo_p1L zi;Jss&H9`jRZhhA%hZ3Oghl{Wt#fp=y*d58$@Lc}xFNfOyg*S(DQE5xrp9AdBpXKL zix$(8c-E(GAxJ2Z% zL+BdY-lE3`ux%mkbsP(D^oq0%4Gm4Xgui|qGcMV-sFDA1)Mn3MepTjq9e_kULH6#A z2hiKdU)E*W=iT7m?XhH|`5&qiZRNA4A2WttJ$quOd-G4Kdf4MG5Hp=05f!yD_+@dm zxQP~APupbLh$#Z#-R4wm)g+ClY+f7DM@&d)D4S5Eqq_}3S-)uIfUt8IFCS!MO-ial zde5zx`MPKr3-=}60)c`6XwU&e5qo)g(FI^1J&-U?ceKh_io$=ybm(b>d#ko1vJk`0 z$*HCBT1cT;ev&`w%1AyYmRr&7; z{ttToU6+vXz1OfbA)%oEy8(Kep_AX$vxBGr1N@>B^}SKn-&87+)h}A|;xyh8pptgl zu=cMTE}1%@on`!k;p5gTD=RS*l?lZuNd*ZBbEN`v;PUBDGt+Han`caz6IQ9;=egr` z5Ix(B-{~x&d}oGz?(s?`UIC=E#Q$B-N}IY=)YR@ob`%WEl)Os#^}SSJ931|>?rp9D zLnh)@ZOtx*r2XGkp}fu7%dv{jf64dqOYr6n>y7=JeSWy0=sdwCn%jRmnHK=q#d;jG zV!Z?)l1cxChfxT9rVIw5xE^vE8vE%liwaOIK@|O@G24379C3Hi&X2|CY?kDn^oM2f ze|tE6#BCN97NE`uPW#A$C1@u3$Gf|$2dt1{!#4XF^^()GUu;$XNfC4Rblj~T5G~%( zr9)iXzccP2l7-dQvgU)-z2xVrgsYU5rFQGvgKOGQ-X0RkO83iA$7c&ei_`sOy9hleMZMuz86dq+6R z%?Dhc$~AEsBqK9xogWiZ7M#Ndz7=S@Pu8mjoNG9hCyY@`NKRh<^=stoH>i=(l=I3?NAH;P4D6`AehTd@{q}L6 zJjP%A-tNYJ$giLo3<-EIa%xh}>#L!iqtW3fx%&Ih`G0eBp)*cn-%a)2w3Ki^X{LT=#KC&V#& zaU3+tJ!(udn(<5PzitufrP-#yRS34{g!ry*Zr`r<^g2)LCfJ4nGYeowyM6#zJh5?i_M zW&DY+%S^voenaN_Jg%|B={s+#k1s|J?bdNyGy2|qO3hV^N3?`{gGTf;Sx~xM+UBU+ zaS$e=SYeSW04@J}J6eeK=Zj*@Opm_S-Xkb79y>P3gRnWpG^JQLjr3Zd7+Yn;UchK- zR+|g9f3UT2*7eaZ|BPBZ-sx7gbM&yl^^^YrG>T!9lJ_(K;CHFgT@{7K-(c|p@IN(m zZO8v`rT^4`bQnsu#?H}ECh!8$3uYVXgZM%B)<$IJVFx37Z5gLQE@Mp+Vyqiz4!)sV z5kf>a4lia)4BVd!e?L>;^9`KJq`Cc60U=Q$a$?+i*$`R_^a~%H|2Ig(JuG4M|J^}_ z5+STF0oo)ZFM6fNLXh9%`(ogR(;&&{#p4y89;P|JhxN8L!ry(0`~uG^e-3s>+O5xY zCIY|-JhBC+d9B!6<-e|`{><;)pANCm=sMW`#b7LzIyV;)Oha1+$zmcHHI013dgm&8 zw6lgORl5w}i4#4Jz0Tjjolv4KXm6jNqinFHEnJ5W2K~t(*9&yU*Ydx7x~$X8y`J}C<_@k zn-ht;`Hg=`?Q~g*pE7AMnARA`=K`1S?L9I&fBn$8^9tI~57!sN)U+r?{G!3PI8h;D zosfPBCb-ozJD~CX_p$y5fAof*;tSw;XEoIDnaMpnr(}q>3-hu4kXt$;V5MLny>?q6 zd#cWroWZ2oH6ee__vqbu%w3^NseF@_((HTW1v62q)~(^W4-pd808^f09$y&ppUAy| zDXDxQA+bH}9nuT#4GiUq^$u}uHf`*QG+Xp?98rCB4D=5YUz^ar*NxNF=j$4UyiK;g zSZByIw^fCek8_&NcmLKBgO5p^*bkg_6#pzW-@o;zyoDy(QrDNV4sK5;J7qYY_&QC4 zcm0~h>;d;Atq%5f9+&xVP!a}1@!n{*&{gqmQCpAlb_Ci)48RE*pr;}XK3OF3%6=<< za;Jxs4f%)HCfPFZ{IupAaJEOGm5yU{1yLTBxL5C0p}Qn>#G2}4Gag-k{;IbGcyPdc zU>`p}4FtIHdM>u&jap?hW@!EbKT<0UvL%HrNu)}PcMFg+f#M*1jWdK5HSE& z=#?ER0$`1B6z@6%#o1DD2@4Mc6=FmWR`UR^5_nZp{ZCz!<0}YwDNLVykbZeJF{pS6 zDVDCS^+KfI0PM0`IndNlvt)$klIWE}PEJnDE8JWo__v7Pbu;?@CN_3_gnUljC*sc@ zR$lF)?Nj7I#}p5%)V(Vh_5s2Crnig$3dtFx|@&|Te8fr3^e~{2wTUmvuyr#NUDD(dW zD!GhIE2V(pdx&3^DW^6A1XiO?$H0z?q!I$=?q7)TM#-<;;y*#>z$_u3eY8Jrw*8~x zQHjfo`ES7vzy=uFP5>YWoLToz@BkenBO)Wi$;CzH#m~=CQN&G&vH{oLg7U2Rse%W3 zDP!gH$g1d$5`H{AtET+$o0hPUkZMO4j|*_#AW+c(Od>HxV=n?`0}GiT0H7%9I9Ba4 zcL5Mux`hA3V7j=<%w!MQ!>yy7^X&qr^nsOSnV% zDKAgf%k{xeI z*0~1hjYg-#8Fv66JotOaV28ZwXlMRw2m!h8!3I1n?X3&;+RbZ%Taae?@5TTTeuAUS zKn7v=Q##vm(&gdie&O!^2zUIepWj*J`!oaF5~I_{!>38>$ZX4Ow%zxSme$uD?d*J( zdMNy{KluN(@n2r`i_|!q(qh~w%a^a>FB0P7o;ljuPJgk=(2zBDyMl}bMyCfkecu82 z`Rm8TM3&`3L44$yg(^E4grJb)zujexNa4Aij!t6EFMI}u8D(u72C(bz`6_xuLXic+ zYWwp1tewdwyJ-b~<-t+bF-zwj9v&WgX~{g|toxdR0cyJ!qmzxZ^YiH}U?w$mskJ9` zGS$~dpO;f=$5hRQFc`V6Sa^SXjyL?)?t3em{CTyE3;B6@$45tdE9N<+0BvG*)RyOn z+<^EmOmDi2Gb(V|usQ)mFYLg#?RTs5F)qVHBRo=?m;?FK9OJj;bA9_={s$Wq#wo56 zpMrYhwl`;sI<$e^?;uOFv;0w84CRtrTHi*=T2gaN6|y504L|Swzk(9*-&XL<4W*2AgkR2d4Y-qCYO{5%5`HZDWiI6>XHzBiyzgG1lX%_sj-E{Ilve3`w5 zaov=bi#;+-dv#Mzfgq42)7J>7HFpsp(HnnY-VKbgps0iC?CeablOgkn#3-Y|y&}y> z8@d|DYRxHktBOWE;S4*})|gaNGY*|7pkV<>n0v97qHZ_tiJ3G46X+G0?~C0Dzt8c> z$jF|Yd#pTLXy#a1EuN;@e!Hp>3JkqORMV}?=k|9DYSH;G)1(yjgsxkA5@SUCmrCy! z@;1HJ4iq_IJXk53=?_AM9d6xbKUWv_Rbl4VNE+gtGy;g0P~iwtHP^2~;8$ zAWJgBLg0foasscLSMFm1KmIDL_&zZIRQuYk>#b7%@}Z@_cM!BIMJW_sBnnd<`efIVD}@8u_5|`5<@pB`2i| zW-c^@X2fP*K+8J%pMxpGF_%deG>P3>paD zzS2y{ySc|#9SpN*t3UIm_<(yhHTgwXYDsl3W-PS8)vf z>8@4QO&U?S3Ud{C(mjWDvCV&OL;F#v@{f;ocaG7Sq%^{dfNW+X<9o(N7eivC46e}Z8DCl@prRFc8Xm$z9a901X%x!+W(F{ z#zwoq|ELM)bdVqY-yF!Ts`uLO3oXqqMl#NH>?;d}|814Ggs{EiH?ab#ipZB_M5T2UFVcR*W;jq($RY&PDM)LCm#2Ai6MGeIdRSBfbqqqN5 zc-2R7Xc2RB3j?Tn3)#+n2jRpIn56MUP4e~dkdXV1V|~NJZXItafPEAa5a{R~|IE$J zeODa$W22R5+@=j^Efu5XD zrwVI^hMIl%c}+`jql$qc<_bZf#0%Kd5(Qq`yar11v28bM(SiPc5~24E{|1sl0@Rt! z&&u4sCgQ%sch77$x|%hxCPB{fJh@%gZD{FJ0GcRzs~#o{JC-XDhSuUYL%V8H(SK1% z&FgC(}an)Oj>#2o)v zWYBPYViFzuuDcm+SPGOI^b(c+FUon5=F&+@^W+xOnxVO|v0FmfhH`%ZVR;%G(((L; ziHCcVGMhkY@>eeo%})P@Dc8M=#I~-QF(MRNmED>{*^j|Ue6w=J-=;&-n@~u=S?1uG zVNsOcPMi`u2Wjmsda|8Dos%9$M5tmx{{xAc4@FC9>BmQ6p*+ezoVf)4X?aDbY@cNK zMO^RU7H9m~`OlS)1Obu}BxJGkB{9CBz zAIlbr%>`Ysh)GO!{kWM#C{%a?XhVz-cM&7Q!<_1AR@^w}EW1gE5TU^{In48Aw>?jT zj_*&CA(3FuRWLtumXfbR84*k{;CQP2@z)K!w0e^P3RY~D*P|xzxW*-DNpiuolPAJacD|OO_!>+Wp9%g2QG;^Y1H>~Ybw^Qd-9SXJ4_EC+!bl6 zFj0&#eZ_didA3wzXDUkaJlXJOm;efgXP(aZ#OqFk+KJi$v8rGk@0aHOfbcSWOxkdH z!Vd@aLDt*5?)iu=?zakbdYiNNYceC0rma`9EhCbs9L4f@PNAL#9tUze)FYXfzGfAV z2t22gR_X%O+MP-j%Ue@1i&5w!#Ae5)pT-Vbv_`9EV8aPjm28Z&*aCZjg&AMd9bQwb&D; zN28fhgiWpePYr_#Yj;i_Ns{j?GGHax?#+EPc3-MD%;9`QaMQ^476&s}Q}k;g4}Qbj zwY9fe!!j>w@KZb~hgo$?tEMTlmX7~$dvBWBiir&O`B27JR1}V!S3Tz88A0Nk&8Ix5 zj_n-mZ-<@}42mZD@r6-H1Mgxbt>oXQqk-oq2Zs>?`Q%dDce?BRtG+DKJkm;wTAbK) zuvwfm#SYk}P+^aPs1k0UPI4PQeFxs& z(Cjn^+eVUmOQwN+%>oa+&9~N9*su!T@ipFjQXg5Bi7=fkzqDE7s*2=#GreBymsGRnti>eiQL5(JA@@Atz-uKooC-P_B#dX=mbcT zZ2jQSQZUC)`%x?ISH|nc_M^2Ek`I33INkN}J3YJWbZeHiokY9eFa%}ATdA;+n+Z%j z1N{AW7CsV?IL^9H)jAcch~TKSd*uiw2ZY1U)>a2y23#T%$>TYt_n7YA-*N2@S9Qjg z(qMc+(=3$7o?bbM=H4GTaToh#E|Vp)ice-uAOvf9s*~nrRXI^TIGz^;$$7n3VW80~ z16JGnhG_P;h|kM@CqA9$*Jj=2o*CHUn%MvDiOonBD&LPIwc$?3@xF+O8&68Z`U6PT z`QqD3U7IAv0uV(T&yfU&;Yu>Qhf0Cq>-+rj_|(X2Xjf zv|x-!fZdc?+wM8Zxv*I)Uzw-0KOZ9L9Az?n<#C>rxTH6hxp5+ATV5hFA!%oy4M~8i z=ab>r4Aha~-*a$Xa~Zvp>*u|mV6OLXF@p-Ub?vrTqDvZ#es$=Q(~dRSVqvBmbKnN2 zEG-p%mI@KMKTlMOyA{pkPk#u4FQr+Y9lh5scP`t!w~EwDtzn(#L6Iq6%tW7`l(l9U z@SqgNlW8UOj2H571JzDBQiyn9TWID@|CD@kjPdcx9cx(N=X&(Fm4%NS{7t{?M>_W1 z%5)@5FT^TJN3e>B^{#f2+rpr;h$}Z^lT3NJh=y|%9?6oi&|kqwBMqyI6?UJYtW zKxi8>pBw>Z!=&pZXUe_X-C21{5TH(yma}1Ks9q}m?T+5A$4k<8!)N0|U))Q;{gS)2 z)jNbHu6vyM3r9Wf>U?o9Zmq|jxg+~rQhL_s6sDd@xwDcmz{=AGH)qWQ20TL(41yota>ZP>J{RORdT zOt__vzS0#!DGi^HVuId~8d%uowgkGNj83O~EVhojolhBw9+J;3r6g*ieuqz#v>HV* zeX{w$+t8AoBl_`EHg&_oibwuJVOX3PL5;ilqG2z>aCL3@&11e=CL zt=@;t<-A{Li%OjlWH8d!o_WH7BRR5PUm>{1@+?D55W=fIobH(chRW>py z!JZz%P@*CZK;VivH%Xb}D0J@ZH!%2HGnX&oalXr)ADAfafPgQSiAi$1PzpBW(nr;0 zIFtHwB-f`qn(zEP;V_r-)V8&mDt0-oG*?HI1y}k(cI|SlHbkS{Hpk+VXLHk96MA9% zHr8%9oQ~R~Cd25SnStPDC%)DHj$O7JJ}0`8A0c#fjeif7(C@OaQkO!zt+T7yBoEY$*gv5 zUe%s0gLiRiV`dWJeu7E#vf>Zq&-+t!gSyqsf5c%*wzg;{m>aXcQ;SZhFb{%>*5!4B za`N#XKfY2lC5^B~d6v0GuFzFWF$MRHsO1VsRES4(@`THy1WHg}PG#2Vg zv6bc2L_9Y2&J$v^_aK70zcAR;&K9%&_M;Qg(_? zV@-t{U8$XFy9(&?(~wG`fcEc9@=D9%G%6T$7sHQKut;!^4ON804SxPL=d9E?(FQV1 znVoij1eBz(Z1iZ{KcD1-*!*oMe#noUL8IJHwh8^t&V44`sg&v0m1Yxu_(z z-+ki8bz;D7kypUPfnu##?}=tmZxnOMlIn9+^Uz8LIBLV20fO>U_wL?rCn~)KsE^(M z!`Q4b8YF5)euzp_yGofpUfG<38HK``u4F1Z-G%VtJWcJJPEMPhsJY(Nz6Sv;Dgd!5 z!7IJhJrx}&_ocH9(bRW3S>3yF9BG*#d~oj7V6eMfASjc%>+(XXzRtQOa6#%bH2S-A9(3WTOkv1>Jd zeH!S>6EIt!DRS3a?4yT9`z4-;m-bT?|EPIX4T;=qcPeF(x5Q(M+!I;;&ZKw$6`m^RtKW)k>~fNHpa!< zHi!hjL*oD67^j5-iornMX4krt{uVlHco8mh* z&)ux1-Pwmtjl5W#9>1HcG1#T-WVhKk-$ViI(xt03pfKDLYZjXGRg>}jmgy=ixOr=7 zcud#RUN}0f)|XaPL00DY_6#?@z-#FitW)2BQL7Tqdu+ZMI zY`P+OLxiP0Y=CR=7%URPSkv204#(8sk?G7CyThooV8+|u z?&CE$HF&Jw6F{-O=VFHCcVbajJ9(?|qV}|LRS+m`j6YKX(AUR7GGooW)7VhM1g3j>S}KlV{xE1_9+UejKE{|dZoU*qav}~^T)YCPpdrkA zQx=p)oZm%cr6WO+#NKGekXGYCYBE~Y8$hChBSzf(QA8jhmDEmYhF1AI7&mGVK(lerja&=$^ogco+K-xG| zIi}2)ahhuq?-75P zkJcEp)NX*I{=`(_X9+J865%#)%J#SS}9}(g))*%h|WdXybtrmKrYVh@F#;kJ-j+R8mYcmeIeD6qsDKHH)y+0ty zpQX|5km@xq`()gJc(&_hd1K4w&K)cHZE6=VWW}{2cTwTf`y_5_2IZ3&#e=u;p{{}0 zjgKdSWUB67?)UfK!WD4BW=ct6#5$1x zYg0`kxq&CH=yv^ZAs~>*Tr}Xt20UE0TSV*it~jIrlnb-d_?g$u5tSzj2<=^Dx0^jg zQqw+Xd7~s5s($<7qeo8RlfCx0;UiP2hjDQuQ-Db~#vOZoND1_S49)ngxUAfb?Rw7E zp#LI|4_>wN?k{*9L{3n$}n&`Y>-5 zDrT+Xfk_j|zmFI8BHU$TNs6rVb(eP4MvNe6sn?M|-e8Vl)nFKY;gku8Xcp(&-0g+U) zUO=n(F78?zUzu=Z2txwyVtvct@B`Sg?WhvK<&7SBX7}88Alc2BS{k-)j{#JTGpq`P zHA=8L(k60V$S;|Fcd@*Nz;QFX`AsS*^3ntAse3F>n0c4t~vcsgxeWm5TR| z5PZ`jit%@4uN)*g!S)!c9?10(k^|+IBPpo@`W>9#2M2J7rGFOH!is)HDqUs#cy!a5M`i^YIrjjnp!=vMoEA z16+p*xq3K`G;jWUzsDn*6zmTZjISBA zln95UEGZ@^+<-k5y#mScQ%n3g-x|r2z7}?Cy#~g0h6D%n664KsWrxOs&D8*_^Uidc zcN8G&i#jMH-Ub!qu(6i!tlhX)wWR<@GWc#~M(Dn4tA3L~#Naf!T3u6l2|L{MPDJP$ z{LMRz0H_3^l$$3biTM@FAPS^klM}lPBD5aT6L2I`loqi99q??9rSpMF2Wvu$wwRNd z);+Z}ZqCWyH+T7z7JIAuLK%DO7wb!OY~puqtmk&QF=k@ubY$IQ&=Vu#Pg%(`C@WX5Ruy(C84`PU& zek%4uxbMCaV!~|w_A#aeLJfl+avq7v!htG)wFMV*i13f5ExIXMGvFqv$&-^`{0*w^ z%F4zT9i@(F=_xK#_^59iY8c`tOtD`w@A}j)=T@PPkKA{>KcmtMFygS}`KTXjhI8tLx2A!B@KV^c@CQl3a8tvBnclV>wP2@^m4;{f~ho1|BmvnQq1+y8d1(l z+$oi4+U>_Wf;pVp1R)03T7e%|T7lHr7oq~^tC$20ei(cPgVt$5o!;Ng`Iht7s8$)9iZPz<+I~4tBLQh?_GwartqaEC zwtdcMiNf7=HKbBkL@{eTbrP0PGVL&ME8|!IrBbCTNf}-3%qM)83EX+?@0KoTx>&E%4reyaX+Kq-J-KUNUJUw8og)b;|R>} z#k$y6yYB)`=;$c8cT-*gN|IqB5@lHG&ir7p*b9m-mtiigdS`gk-hIjoig)YgF9prN z8YQ3$70U#=mcm2db9P$-rGo)Xd?M$zrycU=G@d^;Xp{h0;hkST0Vyffx@t6S>>r~U z5iA3(S#?W9VkrrvT@^IuWD~0Eji3HGVB`q_l8+=Z zuUN`W5G!JnKfDrIfKIzrFdlH@WrJcQzKg(;{L^zXdivoqDa%p7tiX(FsxV6gkU{)Z z!20OM$*-2nW!ehELs1q@?A0dG)vmwYyPvO-etl}BHD*R+4yay?ZUR_vwj!JF5gK?r zzFz*d`yWV<7h$$2S*a|A5M;6&Yw@fcVJT{=aQ>>TWXs8Sc?O#7O8|~1x)Z4aqJ$jA zT(=sf=yAeE53VJ@&Y_hf+T%CZYy45rlrj4+8EAd3xyf$ya*3JeF_HJ?)M?6yP)MB< zsL-VP&CZzCW2>SY^Yin*z6&In=(MxPI?g%Y>~H&^%v3k2Q(XRbNQoLS?2fTJ8o zRP{B(tlkN;LWk3D)ph{|eH!?Fxp}|e@r^VDE zP1p-JijFnX@yi)MVjO*Oq+gK%sd?q4&Gl@}23|F{{hV`9;vufj3caX`Q!!00$zP zdix%62}=7Xe=Sgm^NfMLYrZ_(wdCx#8-aSKHeVl>h|c)}JfuX0$^cm$h9>~#rzYJ$uDh22rYGdrT1G&4`1! zm8E{qp)tt+Gxav#MJxpqCkOu#a}db%u|}u8ze0^He&HA}IK~biTH4ap(*tItU0bVt z-p-9Ko9UaZ5qF-f<~a$9yif@8UbPXk$R(eAdm!hx02gKWo1Pf~g)j-Ba1-4dKbzpb zWMpitCuut6XU~4-Msquj>ekp;jj#6{cyWv`?f>=?KEN4* z``leS&Ry}L8+vwjg^i7k2?_4vBJON#R--M6v^7FQbKD)>6EsdFPH|B6JiD$-)xQV( zfqy7)HR~3ar^MBHLwFehR_@o-gF-ndHr$#3RzVeh>5!N(WBPUZ62giG!Dv}3~=Q>IUa&;isQ*9-f^Gkr<%4t$1 zd1CoN)9$Yg(_ZnJ$oaI0s%agF@xF*L(ZdcF#5Nu~k53fhX=;-1vx57w=5Twt&t>tw zZpzd9B8e1d4NGA`>q0-&nxcE%+#pMOEcqmWBBF&VeY-cn) z@BNuk3Y zb`#LFu_}#EpPgPwX8HN-uRBCyJHyXd6%Ee6OnI3uAn>n~rs#QY}M-flPc zeOOBMjQcj^{QT$GjNY(J!Xgj{)>p=e1~I&*rnda)$cS`4eCEP=pFIR~XWX^>^e7GmdEyD&c|<(C(7+7jGN8-P6Om^+*6|s8Z9T?sLD$ZO{LOE2OhNEnc@$Oeaj>G z?!lAH{)>8~v27R#%BdG4MM`hA=E;PKdT`4S;sj<)B5}904$l&>$!$D=XK8UMKZBjM zK-mZ#KIK~q*7G#Rh8ugNN+oSj4S7l%8xD4b3FRYcs14La6^bW9Z)?GnC>zO=q zF$?Xr&`00qc)q-tsL>o+`UJp12+3G~&Nybs%d))xloZZ24r7X$E$u(4ie) znzxJStjk5EllUoF8OzT{rjiaqGkqfbe)k4_R}vXc8vDoU+Fz073B9aUy;6TAwzL6_m`Kd-FP|Mm1%iT_LZ5Vcl< zlhV1Qxu=T9o}3;0uE-!f#aq8}>&1?W4PB$rO4Kw~lKLZSQx7kDCPOo6rGcM52cn2e zY3@9Y!X}u1Ln~^+oqrCRuNv@Z#%JAKjy`2{n{Xi`pK@k{)?h<9SWMJjx~!sfl4bSL zo0Z^S+iH_!MP=yCv$PE5G&;|cHwdb$J&kQ|5jr%N)}gOpUYZ*The>vq)$Y4fJt@w- zuoskl{E0N50+QxD?FN~}E-B2hth2dQ>$aJ_q2aT|x~k`~|667Q6IpRQu6oi7`qD`GfPdeu~sJfa+Ess1$3UVlrxyS z=U&-;ju&28sZ1EOXHN@HlzT@LCzrRPB!uCYyLx?RjzDI}RS|=LE320yP`3a2uqhZw zilUG?z=m9$Mr@*L$i~j{46`x0$%ZjJJbd55#`$3>ltaO>a=}0=k-XgVEyAr>p4G>A zbkp#adL4+1IC6Y2#0Wwyj;x}o7OYMA?fF0|shbBE@n+c7L;q2?*iXn>s}yNNsvg{o zLlub{uRlTfHO5`rPs{!+jy9~xVtk!h8VTzuX%B6yJnb!#(1D>Y<;ODoiU56P-W+%& z3@Lc&lWQMI${r_0iBOlC7tOB)sxF*2KSOh)F!{!jlDERHiewv&`EpQe)V`C@|I2AZ zzm_Bmxs*mvrH(3Bl7|dph@QJfxtqMbuAqNe@K5!3=)fgj!2&V?B9aS(Tc7_;wjD#Z zy4t3ctMrMiIlPh>DysrYF7g0<$`atWaF{iP)~*ZkKD}iLdn7;fkWo*^~ zl=`JT{ckNz_7^$jrortCE&Lx-z3`^)SMXiPj6}e}r@cf+c@Hsig(~AcFfD;^=l;%{ zOP(dv7yA98PF|*)KV+)?opR{^Y7E5hhg^=MJGTZd^a-Pa+x}ZP`JNYi zop-_gV?F%+`@e0U|IF(`mid-g{kzcF{2v40ytDYbWiR!=KFy-#-Q_$hg)n*?8p*#4fVcq-i&qa{Eq2Eolsrj!v(Wjq)@k;!S;AVg z+=Fw3Gdi>+#Rb#+EhPlZtKUF#w4qj_pq;{*zx8mQrmZbU6Q{;Q;7!#wL#%9Uc|bc4 zZ0FDqK&diq^y-U$gg4vp{cqF2Kg;~-_iqbXSy_d6o8;u=t&?~4SMuKi6?qz*-k3gN z_l>Eie)n`w0j#zGMgXhnV1|U?Z*d`a*gt))cOC>=?X8ZaMjLbkNJQHy@5I_BCzBkn z8OGQEHHAT&V-XkS+SdLO;H{m-9ssTE(c}I)FzB<~`%V1e`?R#QfYPuu{J7NlmFd8j z#n7Wf+_*Hv|`RkV1S;pBL!=h2FsuqU$=GN9PjEzG!hyV3f^7ATiS{}0|m7XWT z)Z7)7mD_z!QYgsDziWYttL?^qLj?FP%(iNge=1H%Fi@n+`UI8k02*1jP4Yw%0Dx1o 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 Jc Date: Fri, 10 Mar 2023 19:43:11 -0300 Subject: [PATCH 17/23] add a __version__ module variable - add this value to 'to_dict' annotation's method --- src/iamsystem/__init__.py | 2 ++ src/iamsystem/matcher/annotation.py | 3 +++ tests/test_annotation.py | 1 + 3 files changed, 6 insertions(+) diff --git a/src/iamsystem/__init__.py b/src/iamsystem/__init__.py index 233b74b..83e4fcd 100644 --- a/src/iamsystem/__init__.py +++ b/src/iamsystem/__init__.py @@ -1,3 +1,5 @@ +__version__ = "0.4.0" + __all__ = [ "Matcher", "IMatcher", diff --git a/src/iamsystem/matcher/annotation.py b/src/iamsystem/matcher/annotation.py index 5088a67..d15a0c4 100644 --- a/src/iamsystem/matcher/annotation.py +++ b/src/iamsystem/matcher/annotation.py @@ -11,6 +11,8 @@ from typing import Tuple from typing import Union +import iamsystem + from iamsystem.brat.formatter import EBratFormatters from iamsystem.keywords.api import IEntity from iamsystem.keywords.api import IKeyword @@ -145,6 +147,7 @@ def to_dict(self, text: str = None) -> Dict[str, Any]: if isinstance(keyword, IEntity) ], "kw_labels": [keyword.label for keyword in self.keywords], + "version": iamsystem.__version__, } if text is not None: text_substring = text[self.start : self.end] # noqa diff --git a/tests/test_annotation.py b/tests/test_annotation.py index a3956de..37cc759 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -220,6 +220,7 @@ def test_to_dict(self): self.assertEqual( dic["substring"], "Another text to check substring is" ) + self.assertEqual(dic["version"], "0.4.0") def test_tokens_states_to_list(self): """Linked list to list returns the right length.""" From 667dc6112259e841380e6646a49520cb6caec005 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 11 Mar 2023 18:27:09 -0300 Subject: [PATCH 18/23] #16 microgramme symbol normalization: add a failing test showing the problem --- tests/test_toknorm.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_toknorm.py b/tests/test_toknorm.py index e5af378..495abed 100644 --- a/tests/test_toknorm.py +++ b/tests/test_toknorm.py @@ -138,6 +138,11 @@ def test_accents_removal(self): norm_str = lower_no_accents(" ulcères ") self.assertEqual(" ulceres ", norm_str) + def test_microgramme_symbol(self): + """Test it normalises μg to ug.""" + norm_str = lower_no_accents("μg") + self.assertEqual("ug", norm_str) + if __name__ == "__main__": unittest.main() From edfcccaa296c2a11e0584dbd3c7312dff07bb562 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 11 Mar 2023 18:28:02 -0300 Subject: [PATCH 19/23] #16 microgramme symbol: replace symbol by u before calling unidecode. Fix #16 --- src/iamsystem/tokenization/normalize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/iamsystem/tokenization/normalize.py b/src/iamsystem/tokenization/normalize.py index 846171d..bf5b47f 100644 --- a/src/iamsystem/tokenization/normalize.py +++ b/src/iamsystem/tokenization/normalize.py @@ -14,5 +14,5 @@ def lower_no_accents(string: str) -> str: def _remove_accents(string: str) -> str: """Remove accents with unidecode library.""" - unaccented_string: str = unidecode_expect_ascii(string) + unaccented_string: str = unidecode_expect_ascii(string.replace("μ", "u")) return unaccented_string From c10601d340d6c3dc1d1b1d10a73e48864c344e02 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 11 Mar 2023 18:30:10 -0300 Subject: [PATCH 20/23] Refactor: rename variables to improve readability --- src/iamsystem/fuzzy/api.py | 20 +-- src/iamsystem/fuzzy/cache.py | 6 +- src/iamsystem/matcher/annotation.py | 99 ++++++----- src/iamsystem/matcher/api.py | 9 +- src/iamsystem/matcher/matcher.py | 16 +- src/iamsystem/matcher/printannot.py | 9 +- src/iamsystem/matcher/strategy.py | 261 ++++++++++++++-------------- src/iamsystem/matcher/util.py | 84 ++++----- src/iamsystem/tree/nodes.py | 12 +- tests/test_annotation.py | 33 ++-- tests/test_detect.py | 4 +- tests/test_tree.py | 4 +- tests/utils_detector.py | 27 +-- 13 files changed, 299 insertions(+), 285 deletions(-) diff --git a/src/iamsystem/fuzzy/api.py b/src/iamsystem/fuzzy/api.py index 19d723c..193be07 100644 --- a/src/iamsystem/fuzzy/api.py +++ b/src/iamsystem/fuzzy/api.py @@ -15,7 +15,7 @@ from iamsystem.fuzzy.util import IWords2ignore from iamsystem.fuzzy.util import SimpleWords2ignore -from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import StateTransition from iamsystem.tokenization.api import TokenT @@ -38,7 +38,7 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - states: Iterable[LinkedState], + transitions: Iterable[StateTransition], ) -> List[SynAlgos]: """Retrieve the synonyms of a token. @@ -47,9 +47,9 @@ def get_synonyms( around the token of interest given by 'i' parameter. :param token: the token of this sequence for which synonyms are expected. - :param states: the states in which the algorithm currently is. - Useful is the fuzzy algorithm needs to know the current states - and the possible state transitions. + :param transitions: the state transitions in which the algorithm + currently is. Useful is the fuzzy algorithm needs to know the next + or possible transitions. :return: 0 to many synonyms. """ raise NotImplementedError @@ -95,7 +95,7 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - states: Iterable[LinkedState], + transitions: Iterable[StateTransition], ) -> List[SynAlgo]: """Main API function to retrieve all synonyms provided by a fuzzy algorithm. @@ -105,9 +105,9 @@ def get_synonyms( around the token of interest given by 'i' parameter. :param token: the token of this sequence for which synonyms are expected. - :param states: the states in which the algorithm currently is. - Useful is the fuzzy algorithm needs to know the current states - and the possible state transitions. + :param transitions: the state transitions in which the algorithm + currently is. Useful is the fuzzy algorithm needs to know the next + or possible transitions. :return: 0 to many synonyms (SynAlgo type). """ raise NotImplementedError @@ -124,7 +124,7 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - states: Iterable[LinkedState], + transitions: Iterable[StateTransition], ) -> List[SynAlgo]: """Delegate to get_syns_of_token.""" return [ diff --git a/src/iamsystem/fuzzy/cache.py b/src/iamsystem/fuzzy/cache.py index aa942ed..6d9e18e 100644 --- a/src/iamsystem/fuzzy/cache.py +++ b/src/iamsystem/fuzzy/cache.py @@ -10,7 +10,7 @@ from iamsystem.fuzzy.api import FuzzyAlgo from iamsystem.fuzzy.api import INormLabelAlgo from iamsystem.fuzzy.api import SynAlgo -from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import StateTransition from iamsystem.tokenization.api import IToken from iamsystem.tokenization.api import TokenT @@ -47,9 +47,9 @@ def get_synonyms( self, tokens: Sequence[IToken], token: TokenT, - states: Iterable[LinkedState], + transitions: Iterable[StateTransition], ) -> List[SynAlgo]: - """Implements superclass abstract method.""" + """Overrides. Implements superclass abstract method.""" word = token.norm_label return self.get_syns_of_word(word=word) diff --git a/src/iamsystem/matcher/annotation.py b/src/iamsystem/matcher/annotation.py index d15a0c4..b4a0ed2 100644 --- a/src/iamsystem/matcher/annotation.py +++ b/src/iamsystem/matcher/annotation.py @@ -19,7 +19,7 @@ from iamsystem.matcher.api import IAnnotation from iamsystem.matcher.api import IBratFormatter from iamsystem.matcher.printannot import PrintAnnot -from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import StateTransition from iamsystem.tokenization.api import TokenT from iamsystem.tokenization.span import Span from iamsystem.tokenization.span import is_shorter_span_of @@ -34,31 +34,11 @@ class Annotation(Span[TokenT], IAnnotation[TokenT]): """Ouput class of :class:`~iamsystem.Matcher` storing information on the detected entities.""" - annot_to_str: Callable[[IAnnotation], str] = PrintAnnot().annot_to_str - " A class function that generates a string representation of an annotation." # noqa - - @classmethod - def set_brat_formatter( - cls, brat_formatter: Union[EBratFormatters, IBratFormatter] - ): - """Change Brat Formatter to change text-span and offsets. - - :param brat_formatter: A Brat formatter to produce - a different Brat annotation. If None, default to - :class:`~iamsystem.ContSeqFormatter`. - :return: None - """ - if isinstance(brat_formatter, EBratFormatters): - brat_formatter = brat_formatter.value - cls.annot_to_str = PrintAnnot( - brat_formatter=brat_formatter - ).annot_to_str - def __init__( self, tokens: List[TokenT], algos: List[List[str]], - last_state: INode, + node: INode, stop_tokens: List[TokenT], text: Optional[str] = None, ): @@ -68,14 +48,14 @@ def __init__( :class:`~iamsystem.IToken` protocol. :param algos: the list of fuzzy algorithms that matched the tokens. One to several algorithms per token. - :param last_state: a final state of iamsystem algorithm containing the + :param node: a final state of iamsystem algorithm containing the keyword that matched this sequence of tokens. :param stop_tokens: the list of stopwords tokens of the document. :param text: the annotated text/document. """ super().__init__(tokens) self._algos = algos - self._last_state = last_state + self._node = node self._stop_tokens = stop_tokens self._text = text @@ -117,7 +97,7 @@ def stop_tokens(self) -> List[TokenT]: def keywords(self) -> Sequence[IKeyword]: """The linked entities, :class:`~iamsystem.IKeyword` instances that matched a document's tokens.""" - return self._last_state.get_keywords() # type: ignore + return self._node.get_keywords() # type: ignore def get_tokens_algos(self) -> Iterable[Tuple[TokenT, List[str]]]: """Get each token and the list of fuzzy algorithms that matched it. @@ -186,6 +166,26 @@ def _get_norm_label_algos_str(self): ] ) + annot_to_str: Callable[[IAnnotation], str] = PrintAnnot().annot_to_str + " A class function that generates a string representation of an annotation." # noqa + + @classmethod + def set_brat_formatter( + cls, brat_formatter: Union[EBratFormatters, IBratFormatter] + ): + """Change Brat Formatter to change text-span and offsets. + + :param brat_formatter: A Brat formatter to produce + a different Brat annotation. If None, default to + :class:`~iamsystem.ContSeqFormatter`. + :return: None + """ + if isinstance(brat_formatter, EBratFormatters): + brat_formatter = brat_formatter.value + cls.annot_to_str = PrintAnnot( + brat_formatter=brat_formatter + ).annot_to_str + def is_ancestor_annot_of(a: Annotation, b: Annotation) -> bool: """True if a is an ancestor of b.""" @@ -193,8 +193,8 @@ def is_ancestor_annot_of(a: Annotation, b: Annotation) -> bool: return False if a.start != b.start or a.end > b.end: return False - ancestors = b._last_state.get_ancestors() - return a._last_state in ancestors + ancestors = b._node.get_ancestors() + return a._node in ancestors def sort_annot(annots: List[Annotation]) -> None: @@ -250,41 +250,44 @@ def rm_nested_annots(annots: List[Annotation], keep_ancestors=False): def create_annot( - last_el: LinkedState, stop_tokens: List[TokenT] + last_trans: StateTransition, stop_tokens: List[TokenT] ) -> Annotation: - """last_el contains a sequence of tokens in text and a final state (a - matcher keyword).""" - if not last_el.node.is_a_final_state(): - raise ValueError("Last element is not a final state.") - last_state = last_el.node - trans_states = linkedlist_to_list(last_el) - # order by token indice. Note that last node is not last anymore. + """last_trans contains all the state transitions and sequence of tokens in + text. The last_trans's node is a final state which means it is associated + with one or many keywords.""" + if not last_trans.node.is_a_final_state(): + raise ValueError("StateTransition's node is not a final state.") + node = last_trans.node + trans_states = _linkedlist_to_list(last_trans) + # order by token indice (important if tokens were ordered alphabetically). + # Note that node might not be the last anymore. trans_states.sort(key=lambda x: x.token.i) tokens: List[TokenT] = [t.token for t in trans_states] algos = [t.algos for t in trans_states] # Note that the annotations are created during iterating over the - # document, when order of tokens is reversed in the Matcher, the list of - # stopwords can be incomplete. So the full stopwords list is passed - # to each annotation, stop_words inside each annotation is filtered later. + # document's tokens. If tokens are ordered alphabetically, + # the list of stopwords inside an annotation are not known at this step. + # Thus, all the stopwords detected are passed to each annotation: + # it's not possible to filter them here, at the moment of creating an + # annotation. annot = Annotation( tokens=tokens, algos=algos, - last_state=last_state, + node=node, stop_tokens=stop_tokens, ) return annot -def linkedlist_to_list(last_el: LinkedState) -> List[LinkedState]: +def _linkedlist_to_list(last_el: StateTransition) -> List[StateTransition]: """Convert a linked list to a list.""" - states: List[LinkedState] = [last_el] - parent = last_el.parent - # it stops when reaching the initial state which parent is None. - while parent.parent is not None: - states.append(parent) - parent = parent.parent - states.reverse() - return states + transitions: List[StateTransition] = [last_el] + previous_trans = last_el.previous_trans + while not StateTransition.is_first_trans(previous_trans): + transitions.append(previous_trans) + previous_trans = previous_trans.previous_trans + transitions.reverse() + return transitions def replace_annots( diff --git a/src/iamsystem/matcher/api.py b/src/iamsystem/matcher/api.py index 8b70b02..2e77270 100644 --- a/src/iamsystem/matcher/api.py +++ b/src/iamsystem/matcher/api.py @@ -39,6 +39,11 @@ def text(self) -> Optional[str]: """Return the annotated text.""" raise NotImplementedError + @text.setter + def text(self, value) -> Optional[str]: + """Set the annotated text.""" + raise NotImplementedError + @property def keywords(self) -> Sequence[IKeyword]: """Keywords linked to this annotation.""" @@ -51,7 +56,7 @@ def to_string(self) -> str: @runtime_checkable -class IBaseMatcher(Protocol): +class IBaseMatcher(Protocol[TokenT]): """Declare the API methods expected by a IAMsystem matcher.""" def annot_text(self, text: str) -> List[IAnnotation[TokenT]]: @@ -100,7 +105,7 @@ def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: @runtime_checkable -class IMatchingStrategy(Protocol): +class IMatchingStrategy(Protocol[TokenT]): """Declare what a matching strategy must implement.""" def detect( diff --git a/src/iamsystem/matcher/matcher.py b/src/iamsystem/matcher/matcher.py index b557db8..61d382f 100644 --- a/src/iamsystem/matcher/matcher.py +++ b/src/iamsystem/matcher/matcher.py @@ -38,7 +38,7 @@ from iamsystem.matcher.api import IMatchingStrategy from iamsystem.matcher.strategy import EMatchingStrategy from iamsystem.matcher.strategy import WindowMatching -from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import StateTransition from iamsystem.stopwords.api import ISimpleStopwords from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.api import IStoreStopwords @@ -198,9 +198,7 @@ def tokenize(self, text: str) -> Sequence[TokenT]: """ return self._tokenizer.tokenize(text=text) - def add_keywords( - self, keywords: Iterable[Union[str, IKeyword, Dict[Any]]] - ) -> None: + def add_keywords(self, keywords: Iterable[Union[str, IKeyword]]) -> None: """Utility function to add multiple keywords. :param keywords: an iterable of string (labels) or @@ -272,19 +270,19 @@ def get_synonyms( self, tokens: Sequence[TokenT], token: TokenT, - states: Iterable[LinkedState], + transitions: Iterable[StateTransition], ) -> List[SynAlgos]: """Get synonyms of a token with configured fuzzy algorithms. :param tokens: document's tokens. :param token: the token for which synonyms are expected. - :param states: algorithm's states. + :param transitions: algorithm's states. :return: tuples of synonyms and fuzzy algorithm's names. """ syns_collector = defaultdict(list) for algo in self.fuzzy_algos: for syn, algo_name in algo.get_synonyms( - tokens=tokens, token=token, states=states + tokens=tokens, token=token, transitions=transitions ): syns_collector[syn].append(algo_name) synonyms: List[SynAlgos] = list(syns_collector.items()) @@ -429,14 +427,14 @@ def add_algo_in_cache(algo=INormLabelAlgo): return add_algo_in_cache - cache = CacheFuzzyAlgos() + cache: CacheFuzzyAlgos = CacheFuzzyAlgos() add_algo_in_cache = _add_algo_in_cache_closure( cache=cache, matcher=matcher ) # Abbreviations if abbreviations is not None: - _abbreviations = Abbreviations(name="abbs") + _abbreviations: Abbreviations[TokenT] = Abbreviations(name="abbs") matcher.add_fuzzy_algo(fuzzy_algo=_abbreviations) for abb in abbreviations: short_form, long_form = abb diff --git a/src/iamsystem/matcher/printannot.py b/src/iamsystem/matcher/printannot.py index e1a7b6e..d2a38d5 100644 --- a/src/iamsystem/matcher/printannot.py +++ b/src/iamsystem/matcher/printannot.py @@ -8,7 +8,8 @@ class PrintAnnot: def __init__(self, brat_formatter: IBratFormatter = None): - """Create a FormatAnnot instance to change annot to_string behavior. + """Create a PrintAnnot instance to change annotation's to_string + method behavior. :param brat_formatter: A Brat formatter to produce a different Brat annotation. If None, default to @@ -25,9 +26,9 @@ def annot_to_str(self, annot: IAnnotation): text-span and offsets are generated by the BratFormatter. """ brat_formatter = self._brat_formatter - # IndividualTokenFormatter doesn't use annotation 'text' to produce - # a valid Brat offsets. The other formatter must not be used if text - # is not set. + # IndividualTokenFormatter doesn't use annotation 'text'. Without text + # It can produce valid Brat offsets. + # Other formatter must not be used if text. if annot.text is None: brat_formatter = TokenFormatter() text_span, offsets = brat_formatter.get_text_and_offsets(annot=annot) diff --git a/src/iamsystem/matcher/strategy.py b/src/iamsystem/matcher/strategy.py index e1d3c9b..a6595b3 100644 --- a/src/iamsystem/matcher/strategy.py +++ b/src/iamsystem/matcher/strategy.py @@ -3,6 +3,7 @@ from enum import Enum from typing import Dict from typing import List +from typing import Optional from typing import Sequence from typing import Set @@ -13,8 +14,7 @@ from iamsystem.matcher.annotation import sort_annot from iamsystem.matcher.api import IAnnotation from iamsystem.matcher.api import IMatchingStrategy -from iamsystem.matcher.util import LinkedState -from iamsystem.matcher.util import create_start_state +from iamsystem.matcher.util import StateTransition from iamsystem.stopwords.api import IStopwords from iamsystem.tokenization.api import TokenT from iamsystem.tokenization.tokenize import Token @@ -40,71 +40,69 @@ def detect( ) -> List[IAnnotation[TokenT]]: """Overrides.""" annots: List[Annotation] = [] - # states stores linkedstate instance that keeps track of a tree path - # and document's tokens that matched. - states: Set[LinkedState] = set() - start_state = create_start_state(initial_state=initial_state) - states.add(start_state) + transitions: Set[StateTransition] = set() + first_trans = StateTransition.create_first_trans( + initial_state=initial_state + ) + transitions.add(first_trans) # count_not_stopword allows a stopword-independent window size. count_not_stopword = 0 stop_tokens: List[TokenT] = [] - new_states: List[LinkedState] = [] - # states2remove store states that will be out-of-reach - # at next iteration. - states2remove: List[LinkedState] = [] + new_trans: List[StateTransition] = [] + trans2remove: List[StateTransition] = [] for i, token in enumerate(tokens): if stopwords.is_token_a_stopword(token): stop_tokens.append(token) continue - # w_bucket stores when a state will be out-of-reach - # 'count_not_stopword % w' has range [0 ; w-1] - w_bucket = count_not_stopword % w - new_states.clear() - states2remove.clear() + new_trans.clear() + trans2remove.clear() count_not_stopword += 1 - # syns: 1 to many synonyms depending on fuzzy_algos configuration. syns_algos: List[SynAlgos] = syns_provider.get_synonyms( - tokens=tokens, token=token, states=states + tokens=tokens, token=token, transitions=transitions ) - - for state in states: - if state.w_bucket == w_bucket: - states2remove.append(state) - # 0 to many states for [0] to [w-1] ; [w] only the start state. + for trans in transitions: + if trans.is_obsolete( + count_not_stopword=count_not_stopword, w=w + ): + trans2remove.append(trans) + continue for syn, algos in syns_algos: - node = state.node.jump_to_node(syn) + next_node = trans.node.jump_to_node(syn) # when no path is found, EMPTY_NODE is returned. - if node is EMPTY_NODE: + if next_node is EMPTY_NODE: continue - new_state = LinkedState( - parent=state, - node=node, + next_trans = StateTransition( + previous_trans=trans, + node=next_node, token=token, algos=algos, - w_bucket=w_bucket, + count_not_stopword=count_not_stopword, ) - new_states.append(new_state) - # Why 'new_state not in states': - # if node_num is already in the states set,it means - # an annotation was already created for this state. - # For example 'cancer cancer', if an annotation was created - # for the first 'cancer' then we don't want to create - # a new one for the second 'cancer'. - if node.is_a_final_state() and new_state not in states: + new_trans.append(next_trans) + # Why 'next_trans not in transitions: + # Don't create multiple annotations for the same transition + # For example 'cancer cancer' with keyword 'cancer': + # if an annotation was created for the first 'cancer' + # occurent, don't create a new one of the second occurence. + if ( + next_node.is_a_final_state() + and next_trans not in transitions + ): annot = create_annot( - last_el=new_state, stop_tokens=stop_tokens + last_trans=next_trans, stop_tokens=stop_tokens ) annots.append(annot) # Prepare next iteration: first loop remove out-of-reach states. # Second iteration add new states. - for state in states2remove: - states.remove(state) - for state in new_states: - # this condition happens in the 'cancer cancer' example. - # the effect is replacing a previous token by a new one. - if state in states: - states.remove(state) - states.add(state) + for trans in trans2remove: + transitions.remove(trans) + for trans in new_trans: + # this condition happens in the 'cancer cancer' example above. + # the effect is replacing a previous transition by a new, + # more recent one. + if trans in transitions: + transitions.remove(trans) + transitions.add(trans) sort_annot(annots) # mutate the list like annots.sort() return annots @@ -117,9 +115,9 @@ class LargeWindowMatching(IMatchingStrategy): """ def __init__(self): - self.states = {} + self.initial_state: Optional[INode] = None + self.transitions = {} self.avaible_trans = {} - self.is_initialized = False def detect( self, @@ -130,74 +128,77 @@ def detect( stopwords: IStopwords, ) -> List[IAnnotation[TokenT]]: """Overrides.""" - if not self.is_initialized: + # create a cache for the initial state: + if self.initial_state is None or self.initial_state != initial_state: self._initialize(initial_state=initial_state) - self.is_initialized = True annots: List[Annotation] = [] - states: Dict[int, LinkedState] = self.states.copy() - # avaible_trans stores which state have a transition to a synonym. + transitions: Dict[int, StateTransition] = self.transitions.copy() + # Avaible_trans stores which transition has a transition with word w. + # This hash table filters transitions and avoid a loop over + # all transitions to check if a transition is possible. avaible_trans: Dict[str, Set[int]] = self.avaible_trans.copy() count_not_stopword = 0 stop_tokens: List[TokenT] = [] - new_states: Set[LinkedState] = set() + new_trans: Set[StateTransition] = set() emptylist = [] for i, token in enumerate(tokens): if stopwords.is_token_a_stopword(token): stop_tokens.append(token) continue - new_states.clear() + new_trans.clear() count_not_stopword += 1 - # syns: 1 to many synonyms depending on fuzzy_algos configuration. syns_algos: List[SynAlgos] = syns_provider.get_synonyms( - tokens=tokens, token=token, states=iter(states.values()) + tokens=tokens, + token=token, + transitions=iter(transitions.values()), ) for syn, algos in syns_algos: - states_id = avaible_trans.get(syn[0], emptylist) - for state_id in states_id.copy(): - state: LinkedState = states.get(state_id, None) - # case a state was obsolete and removed: - if state is None: - states_id.remove(state_id) + transitions_id = avaible_trans.get(syn[0], emptylist) + for trans_id in transitions_id.copy(): + trans: StateTransition = transitions.get(trans_id, None) + # case a transition was obsolete and removed: + if trans is None: + transitions_id.remove(trans_id) continue - # case a state is obsolete and removed here: - if state.is_obsolete( - count_stop_word=count_not_stopword, w=w + # case a transition is obsolete, we remove it now: + if trans.is_obsolete( + count_not_stopword=count_not_stopword, w=w ): - del states[state_id] - states_id.remove(state_id) + del transitions[trans_id] + transitions_id.remove(trans_id) continue - node = state.node.jump_to_node(syn) + node = trans.node.jump_to_node(syn) # when no path is found, EMPTY_NODE is returned. if node is EMPTY_NODE: # I could raise a valueError here since it should be # impossible: all the states have a transition to # the current synonym. continue - new_state = LinkedState( - parent=state, + next_trans = StateTransition( + previous_trans=trans, node=node, token=token, algos=algos, - w_bucket=count_not_stopword, + count_not_stopword=count_not_stopword, ) - new_states.add(new_state) - for state in new_states: + new_trans.add(next_trans) + for trans in new_trans: # create an annotation if: # 1) node is a final state # 2) an annotation wasn't created yet for this state: # 2.1 there is no previous 'none-obsolete state'. - if state.node.is_a_final_state(): - old_state = states.get(state.id, None) - if old_state is None or old_state.is_obsolete( - count_stop_word=count_not_stopword, w=w + if trans.node.is_a_final_state(): + old_trans = transitions.get(trans.id, None) + if old_trans is None or old_trans.is_obsolete( + count_not_stopword=count_not_stopword, w=w ): annot = create_annot( - last_el=state, stop_tokens=stop_tokens + last_trans=trans, stop_tokens=stop_tokens ) annots.append(annot) - for nexttoken in state.node.get_child_tokens(): - avaible_trans[nexttoken].add(state.id) - states[state.id] = state + for nexttoken in trans.node.get_children_tokens(): + avaible_trans[nexttoken].add(trans.id) + transitions[trans.id] = trans sort_annot(annots) # mutate the list like annots.sort() return annots @@ -208,19 +209,22 @@ def _initialize(self, initial_state: INode) -> None: :param initial_state: the initial state (eg. root node). :return: None. """ - self.states: Dict[int, LinkedState] = {} + self.initial_state = initial_state + self.transitions: Dict[int, StateTransition] = {} self.avaible_trans: Dict[str, Set[int]] = defaultdict(set) - start_state = create_start_state(initial_state=initial_state) - self.states[start_state.id] = start_state - for token in start_state.node.get_child_tokens(): - self.avaible_trans[token].add(start_state.id) + first_trans = StateTransition.create_first_trans( + initial_state=initial_state + ) + self.transitions[first_trans.id] = first_trans + for token in first_trans.node.get_children_tokens(): + self.avaible_trans[token].add(first_trans.id) class NoOverlapMatching(IMatchingStrategy): """The old matching strategy that was in used till 2022. The 'w' parameter has no effect. It annotates the longest path and outputs no overlapping annotation - except in case of ambiguity. It's the fastest strategy. + except in case of ambiguity. Algorithm formalized in https://ceur-ws.org/Vol-3202/livingner-paper11.pdf # noqa """ @@ -243,86 +247,86 @@ def detect( """Overrides. Note that w parameter is ignored and so has no effect.""" annots: List[Annotation] = [] - # states stores linkedstate instance that keeps track of a tree path - # and document's tokens that matched. - states: Set[LinkedState] = set() - start_state = create_start_state(initial_state=initial_state) - states.add(start_state) + transitions: Set[StateTransition] = set() + first_trans = StateTransition.create_first_trans( + initial_state=initial_state + ) + transitions.add(first_trans) stop_tokens: List[TokenT] = [] # i stores the position of the current token. i = 0 - # started_at is used for back-tracking, it stores the initial 'i' - # from which the initial state started. + # started_at is used for back-tracking, it stores the 'i' value + # from which a state transition started. If the search founds nothing + # i is reset to last i value + 1. started_at = 0 - # I create a copy of the tokens sequence and append 'END_TOKEN' - # The goal of this 'END_TOKEN' is to generate a dead end for the last - # new_states which force to enter case '2)' below. - tokens_copy = list(tokens) - tokens_copy.append(NoOverlapMatching.END_TOKEN) - while i < len(tokens_copy): - token = tokens_copy[i] + while i < len(tokens) + 1: + # The goal of this 'END_TOKEN' is to generate a dead end + # for the last transitions (end of the document). + if i == len(tokens): + token = NoOverlapMatching.END_TOKEN + else: + token = tokens[i] if stopwords.is_token_a_stopword(token): stop_tokens.append(token) i += 1 started_at += 1 continue - new_states: Set[LinkedState] = set() - # syns: 1 to many synonyms depending on fuzzy_algos configuration. + new_trans: Set[StateTransition] = set() syns_algos: List[SynAlgos] = syns_provider.get_synonyms( - tokens=tokens, token=token, states=states + tokens=tokens, token=token, transitions=transitions ) - for state in states: + for trans in transitions: for syn, algos in syns_algos: - node = state.node.jump_to_node(syn) + node = trans.node.jump_to_node(syn) # when no path is found, EMPTY_NODE is returned. if node is EMPTY_NODE: continue - new_state = LinkedState( - parent=state, + next_trans = StateTransition( + previous_trans=trans, node=node, token=token, algos=algos, - w_bucket=-1, + count_not_stopword=-1, ) - new_states.add(new_state) + new_trans.add(next_trans) # 1) Case the algorithm is exploring a path: - if len(new_states) != 0: - states = new_states + if len(new_trans) != 0: + transitions = new_trans i += 1 - # don't 'started_at += 1' to allow backtracking later. + # don't do 'started_at += 1' to allow backtracking later. # 2) Case the algorithm has finished exploring a path: else: # the algorithm has gone nowhere from initial state: - if len(states) == 1 and start_state in states: + if len(transitions) == 1 and first_trans in transitions: i += 1 started_at += 1 continue # the algorithm has gone somewhere. Save annotations and - # restart at last annotation ith token + 1 (no overlap). + # restart at last annotation ith token + 1. last_i = self._add_annots( annots=annots, - states=states, + transitions=transitions, started_at=started_at, stop_tokens=stop_tokens, ) i = last_i + 1 started_at = started_at + 1 - states.clear() - states.add(start_state) + transitions.clear() + transitions.add(first_trans) sort_annot(annots) # mutate the list like annots.sort() return annots @staticmethod def _add_annots( annots: List[Annotation], - states: Set[LinkedState], + transitions: Set[StateTransition], started_at: int, stop_tokens: List[TokenT], ) -> int: """Create annotations and mutate annots list. :param annots: the list of annotations. - :param states: the current algorithm's states. + :param transitions: the current algorithm's states. :param started_at: the 'i' token at whcih the algorithm started a search. :param stop_tokens: stopwords @@ -330,18 +334,17 @@ def _add_annots( generated. """ last_annot_i = -1 - for state in states: - current_state = state + for trans in transitions: + current_trans = trans # back track till the first state that is a final state # ex: 'cancer de la', backtrack to 'cancer'. - while ( - not current_state.node.is_a_final_state() - and current_state.parent is not None - ): - current_state = current_state.parent - if current_state.node.is_a_final_state(): + while not current_trans.node.is_a_final_state(): + current_trans = current_trans.previous_trans + if StateTransition.is_first_trans(current_trans): + break + if current_trans.node.is_a_final_state(): annot = create_annot( - last_el=current_state, stop_tokens=stop_tokens + last_trans=current_trans, stop_tokens=stop_tokens ) last_annot_i = max(annot.end_i, last_annot_i) annots.append(annot) diff --git a/src/iamsystem/matcher/util.py b/src/iamsystem/matcher/util.py index 0366e1a..801fd18 100644 --- a/src/iamsystem/matcher/util.py +++ b/src/iamsystem/matcher/util.py @@ -11,71 +11,75 @@ from iamsystem.tree.nodes import INode -class LinkedState(Generic[TokenT]): +class StateTransition(Generic[TokenT]): """Keep track of the sequence of tokens in a document that matched - the sequence of tokens of a keyword. This object is a linked list, - the first element the create_start_state, others are transition_state.""" + the sequence of tokens of a keyword. A state transition occurs between + two states (nodes in a trie) with a word w. This object is a linked list, + the first element is a start_state (root node in general).""" def __init__( self, - parent: Optional[LinkedState], + previous_trans: Optional[StateTransition], node: INode, token: TokenT, algos: List[str], - w_bucket: int, + count_not_stopword: int, ): """ - :param parent: the previous state. + :param previous_trans: the previous transition. :param node: the current state. + :param algos: the algorthim(s) that matched that matched the token + to the node. :param token: token from a document, a generic type that implements :class:`~iamsystem.IToken` protocol. - :param algos: the algorthim(s) that matched this state's token - (from a keyword) to the document's token. - :param w_bucket: the window bucket used to test if this instance - becomes out-of-reach and should be remove. + :param count_not_stopword: the ith not stopword token. This value is + used to check if a transition becomes out-of-reach and + should be removed. """ self.node = node self.token = token - self.parent = parent + self.previous_trans = previous_trans self.algos = algos - self.w_bucket = w_bucket + self.w_bucket = count_not_stopword self.id = node.node_num - def is_obsolete(self, count_stop_word: int, w) -> bool: - distance_2_current_token = count_stop_word - self.w_bucket - if (w - distance_2_current_token < 0) and self.parent is not None: - return True - return False + def is_obsolete(self, count_not_stopword: int, w: int) -> bool: + """Check if a state transition is obsolete given a window size.""" + distance_2_current_token = count_not_stopword - self.w_bucket + return ( + w - distance_2_current_token < 0 + ) and not StateTransition.is_first_trans(self) + # Start state is never obsolete. It allows starting a new transition + # sequence at any token (except stopword). def __eq__(self, other): """Two nodes are equal if they have the same number.""" - # I removed these verifications to speed up the algorithm. - # if self is other: - # return True - # if isinstance(other, int): - # return self.node.node_num == other - # if not isinstance(other, LinkedState): - # return False - # other_state: LinkedState = other + # No type checking to speed up the algorithm. return self.id == other.id def __hash__(self): - """Uses the node number as a unique identifier.""" + """Use the node number as a unique identifier.""" return self.id + @classmethod + def is_first_trans(cls, trans: StateTransition): + """Check a transition is the first one.""" + return trans.previous_trans is None -def create_start_state(initial_state: INode): - return LinkedState( - parent=None, - node=initial_state, - token=Token( - start=-1, - end=-1, - norm_label="START_TOKEN", - i=-1, - label="START_TOKEN", - ), - algos=[], - w_bucket=-1, - ) + @classmethod + def create_first_trans(cls, initial_state: INode): + """Create the first transition with the initial state.""" + return StateTransition( + previous_trans=None, + node=initial_state, + token=Token( + start=-1, + end=-1, + norm_label="START_TOKEN", + i=-1, + label="START_TOKEN", + ), + algos=[], + count_not_stopword=-1, + ) diff --git a/src/iamsystem/tree/nodes.py b/src/iamsystem/tree/nodes.py index 65a4a60..908c269 100644 --- a/src/iamsystem/tree/nodes.py +++ b/src/iamsystem/tree/nodes.py @@ -38,7 +38,7 @@ def add_child_node(self, node: INode) -> None: raise NotImplementedError @abstractmethod - def get_child_nodes(self) -> Iterable[INode]: + def get_children_nodes(self) -> Iterable[INode]: """Retrive child nodes.""" raise NotImplementedError @@ -79,7 +79,7 @@ def get_token(self) -> str: raise NotImplementedError @abstractmethod - def get_child_tokens(self) -> Iterable[str]: + def get_children_tokens(self) -> Iterable[str]: """Return the children token.""" raise NotImplementedError @@ -111,7 +111,7 @@ def add_child_node(self, node: INode) -> None: """This method shouldn't be called.""" raise NotImplementedError - def get_child_nodes(self) -> Iterable[INode]: + def get_children_nodes(self) -> Iterable[INode]: """This method shouldn't be called.""" raise NotImplementedError @@ -127,7 +127,7 @@ def get_token(self) -> str: """This method shouldn't be called.""" raise NotImplementedError - def get_child_tokens(self) -> Iterable[str]: + def get_children_tokens(self) -> Iterable[str]: """Return the children token.""" raise NotImplementedError @@ -208,7 +208,7 @@ def get_ancestors(self) -> Sequence[INode]: ancest = ancest.parent_node return ancestors - def get_child_nodes(self) -> Iterable[INode]: + def get_children_nodes(self) -> Iterable[INode]: """Return all the child of this node that correspond to all possible state transitions.""" return self.childNodes.values().__iter__() @@ -222,7 +222,7 @@ def get_token(self) -> str: """Return the token associated to this node.""" return self.token - def get_child_tokens(self) -> Iterable[str]: + def get_children_tokens(self) -> Iterable[str]: """Return the childs' tokens.""" for token in self.childNodes.keys(): yield token diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 37cc759..9becf7a 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -2,14 +2,13 @@ from iamsystem.keywords.keywords import Entity from iamsystem.matcher.annotation import Annotation +from iamsystem.matcher.annotation import _linkedlist_to_list from iamsystem.matcher.annotation import create_annot from iamsystem.matcher.annotation import is_ancestor_annot_of -from iamsystem.matcher.annotation import linkedlist_to_list from iamsystem.matcher.annotation import rm_nested_annots from iamsystem.matcher.annotation import sort_annot from iamsystem.matcher.matcher import Matcher -from iamsystem.matcher.util import LinkedState -from iamsystem.matcher.util import create_start_state +from iamsystem.matcher.util import StateTransition from iamsystem.tokenization.span import is_shorter_span_of from iamsystem.tree.trie import Trie from tests.utils_detector import get_gauche_el_in_ivg @@ -152,7 +151,7 @@ def test_create_annot(self): gauche_node, gauche_el = get_gauche_el_in_ivg() ent = Entity("Insuffisance Cardiaque Gauche", "I50.1") gauche_node.add_keyword(ent) - annot: Annotation = create_annot(last_el=gauche_el, stop_tokens=[]) + annot: Annotation = create_annot(last_trans=gauche_el, stop_tokens=[]) self.assertEqual(3, len(annot._tokens)) self.assertTrue(ent in annot.keywords) self.assertEqual(0, annot.start) @@ -161,35 +160,35 @@ def test_create_annot(self): substring = text[annot.start : annot.end] # noqa self.assertEqual("Insuffisance Ventriculaire Gauche", substring) - def test_create_start_state(self): + def test_is_start_state(self): """When parent is none, it's the start_state""" trie = Trie() - start_state = create_start_state( + start_state = StateTransition.create_first_trans( initial_state=trie.get_initial_state() ) - self.assertTrue(start_state.parent is None) + self.assertTrue(StateTransition.is_first_trans(start_state)) def test_transition_state_equality(self): """Two transititions states are 'equal' if they have the same node number. This equality is important since a 'new' state needs to override an existing state.""" gauche_node, gauche_el = get_gauche_el_in_ivg() - trans_state_0 = LinkedState( - parent=None, + trans_state_0 = StateTransition( + previous_trans=None, node=gauche_node, token=self.annots[0].tokens[0], algos=["one"], - w_bucket=0, + count_not_stopword=0, ) - start_state = create_start_state( + start_state = StateTransition.create_first_trans( initial_state=Trie().get_initial_state() ) - trans_state_1 = LinkedState( - parent=start_state, + trans_state_1 = StateTransition( + previous_trans=start_state, node=gauche_node, token=None, # noqa algos=["one"], - w_bucket=0, + count_not_stopword=0, ) self.assertEqual(trans_state_0, trans_state_1) trans_set = set() @@ -205,7 +204,7 @@ def test_to_dict(self): gauche_node, gauche_el = get_gauche_el_in_ivg() ent = Entity("Insuffisance Cardiaque Gauche", "I50.1") gauche_node.add_keyword(ent) - annot: Annotation = create_annot(last_el=gauche_el, stop_tokens=[]) + annot: Annotation = create_annot(last_trans=gauche_el, stop_tokens=[]) dic = annot.to_dict(text="Another text to check substring is working") self.assertEqual(dic["start"], 0) self.assertEqual(dic["end"], 34) @@ -225,7 +224,7 @@ def test_to_dict(self): def test_tokens_states_to_list(self): """Linked list to list returns the right length.""" gauche_node, gauche_el = get_gauche_el_in_ivg() - tokens_states = linkedlist_to_list(last_el=gauche_el) + tokens_states = _linkedlist_to_list(last_el=gauche_el) self.assertEqual(3, len(tokens_states)) def test_node_not_in_final_state(self): @@ -233,7 +232,7 @@ def test_node_not_in_final_state(self): (= node is a final state). Otherwise an exception is raised.""" gauche_node, gauche_el = get_gauche_el_in_ivg() with (self.assertRaises(ValueError)): - create_annot(last_el=gauche_el, stop_tokens=[]) + create_annot(last_trans=gauche_el, stop_tokens=[]) def test_stop_tokens(self): """the list of stop_tokens is correct.""" diff --git a/tests/test_detect.py b/tests/test_detect.py index b4f42b7..9aed81b 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -14,7 +14,7 @@ from iamsystem.matcher.annotation import rm_nested_annots from iamsystem.matcher.matcher import Matcher from iamsystem.matcher.strategy import WindowMatching -from iamsystem.matcher.util import LinkedState +from iamsystem.matcher.util import StateTransition from iamsystem.stopwords.api import IStopwords from iamsystem.stopwords.simple import Stopwords from iamsystem.tokenization.api import IToken @@ -305,7 +305,7 @@ def get_synonyms( self, tokens: Sequence[TokenPOS], token: TokenPOS, - states: List[List[LinkedState]], + transitions: List[List[StateTransition]], ) -> Iterable[SynAlgo]: """Returns only if POS is NOUN""" if token.pos == "NOUN": diff --git a/tests/test_tree.py b/tests/test_tree.py index a453cce..9e022e9 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -165,8 +165,8 @@ def test_get_child_nodes(self): """insuffisance -> cardiaque""" ins_node = Node(token="insuffisance", node_num=1) card_node = Node(token="cardiaque", node_num=2, parent_node=ins_node) - self.assertEqual(1, len(list(ins_node.get_child_nodes()))) - self.assertEqual(0, len(list(card_node.get_child_nodes()))) + self.assertEqual(1, len(list(ins_node.get_children_nodes()))) + self.assertEqual(0, len(list(card_node.get_children_nodes()))) def test_goto_node(self): """insuffisance -> cardiaque.""" diff --git a/tests/utils_detector.py b/tests/utils_detector.py index c04ba06..c3587b4 100644 --- a/tests/utils_detector.py +++ b/tests/utils_detector.py @@ -3,8 +3,7 @@ from iamsystem.fuzzy.abbreviations import Abbreviations from iamsystem.keywords.collection import Terminology from iamsystem.keywords.keywords import Entity -from iamsystem.matcher.util import LinkedState -from iamsystem.matcher.util import create_start_state +from iamsystem.matcher.util import StateTransition from iamsystem.tokenization.token import Token from iamsystem.tokenization.tokenize import french_tokenizer from iamsystem.tree.nodes import Node @@ -30,23 +29,25 @@ def get_abbs_irc() -> Abbreviations: return abbs -def get_gauche_el_in_ivg() -> Tuple[Node, LinkedState]: +def get_gauche_el_in_ivg() -> Tuple[Node, StateTransition]: """Return a transition state.""" # root_node trie = Trie() root_node = trie.get_initial_state() - root_el = create_start_state(initial_state=trie.root_node) + start_state = StateTransition.create_first_trans( + initial_state=trie.root_node + ) # insuffisance ins_node = Node(token="insuffisance", node_num=1, parent_node=root_node) ins_span = Token( label="Insuffisance", norm_label="insuffisance", start=0, end=12, i=0 ) - ins_el: LinkedState = LinkedState( + ins_el: StateTransition = StateTransition( node=ins_node, token=ins_span, - parent=root_el, + previous_trans=start_state, algos=["exact"], - w_bucket=0, + count_not_stopword=0, ) # ventriculaire vent_node = Node(token="ventriculaire", node_num=2, parent_node=ins_node) @@ -57,24 +58,24 @@ def get_gauche_el_in_ivg() -> Tuple[Node, LinkedState]: end=26, i=1, ) - vent_el: LinkedState = LinkedState( + vent_el: StateTransition = StateTransition( node=vent_node, token=vent_span, - parent=ins_el, + previous_trans=ins_el, algos=["exact"], - w_bucket=0, + count_not_stopword=0, ) # gauche gauche_node = Node(token="gauche", node_num=3, parent_node=vent_node) gauche_span = Token( label="Gauche", norm_label="gauche", start=28, end=34, i=2 ) - gauche_el: LinkedState = LinkedState( + gauche_el: StateTransition = StateTransition( node=gauche_node, token=gauche_span, - parent=vent_el, + previous_trans=vent_el, algos=["exact"], - w_bucket=0, + count_not_stopword=0, ) return gauche_node, gauche_el From 2c6d086eb02696b9d8ec366cfdbda6c923235229 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 11 Mar 2023 17:19:14 -0300 Subject: [PATCH 21/23] bratformatters: create a function to remove duplicated code --- src/iamsystem/brat/formatter.py | 24 ++++++------------------ src/iamsystem/tokenization/util.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/iamsystem/brat/formatter.py b/src/iamsystem/brat/formatter.py index 7c06798..a4076aa 100644 --- a/src/iamsystem/brat/formatter.py +++ b/src/iamsystem/brat/formatter.py @@ -1,21 +1,14 @@ from enum import Enum -from typing import List from typing import Tuple from iamsystem.brat.util import get_brat_format_seq from iamsystem.matcher.api import IAnnotation from iamsystem.matcher.api import IBratFormatter -from iamsystem.tokenization.api import IOffsets +from iamsystem.tokenization.util import get_text_and_offsets_of_sequences from iamsystem.tokenization.util import group_continuous_seq -from iamsystem.tokenization.util import multiple_seq_to_offsets from iamsystem.tokenization.util import remove_trailing_stopwords -def get_text_span(text: str, offsets: IOffsets): - """Return the text substring of an offsets.""" - return text[offsets.start : offsets.end] # noqa - - class ContSeqFormatter(IBratFormatter): """Default Brat Formatter: annotate a document by selecting continuous sequences of tokens but ignore stopwords.""" @@ -23,12 +16,9 @@ class ContSeqFormatter(IBratFormatter): def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: """Return tokens' labels and token's offsets (merge if continuous)""" sequences = group_continuous_seq(tokens=annot.tokens) - offsets: List[IOffsets] = multiple_seq_to_offsets(sequences=sequences) - seq_offsets = get_brat_format_seq(offsets) - seq_label = " ".join( - [get_text_span(annot.text, one_offsets) for one_offsets in offsets] + return get_text_and_offsets_of_sequences( + sequences=sequences, annot=annot ) - return seq_label, seq_offsets class TokenFormatter(IBratFormatter): @@ -64,11 +54,9 @@ def get_text_and_offsets(self, annot: IAnnotation) -> Tuple[str, str]: sequences = remove_trailing_stopwords( sequences=sequences, stop_i=stop_i ) - seq_tokens = [token for seq in sequences for token in seq] - seq_label = " ".join([token.label for token in seq_tokens]) - offsets = multiple_seq_to_offsets(sequences=sequences) - seq_offsets = get_brat_format_seq(offsets) - return seq_label, seq_offsets + return get_text_and_offsets_of_sequences( + sequences=sequences, annot=annot + ) class SpanFormatter(IBratFormatter): diff --git a/src/iamsystem/tokenization/util.py b/src/iamsystem/tokenization/util.py index 3b23534..8d112b5 100644 --- a/src/iamsystem/tokenization/util.py +++ b/src/iamsystem/tokenization/util.py @@ -4,6 +4,8 @@ from typing import Sequence from typing import Tuple +from iamsystem.brat.util import get_brat_format_seq +from iamsystem.matcher.api import IAnnotation from iamsystem.tokenization.api import IOffsets from iamsystem.tokenization.api import IToken from iamsystem.tokenization.tokenize import Offsets @@ -132,6 +134,23 @@ def remove_trailing_stopwords( return out_seq +def get_text_span(text: str, offsets: IOffsets) -> str: + """Return the text substring of an offsets.""" + return text[offsets.start : offsets.end] # noqa + + +def get_text_and_offsets_of_sequences( + sequences: List[List[IToken]], annot: IAnnotation +) -> Tuple[str, str]: + """Return text of brat offsets from multiple sequences.""" + offsets: List[IOffsets] = multiple_seq_to_offsets(sequences=sequences) + seq_offsets = get_brat_format_seq(offsets) + seq_label = " ".join( + [get_text_span(annot.text, one_offsets) for one_offsets in offsets] + ) + return seq_label, seq_offsets + + def multiple_seq_to_offsets(sequences: List[List[IToken]]) -> List[IOffsets]: """Create an Offsets for each continuous sequence, start being the start offset of the first token in the sequence and From bcb4f48a13969a4571c461ad704843dcac20de70 Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 11 Mar 2023 19:33:06 -0300 Subject: [PATCH 22/23] change __version__ by __annot_version__ in to_dict Annotation method --- src/iamsystem/__init__.py | 4 ++-- src/iamsystem/matcher/annotation.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/iamsystem/__init__.py b/src/iamsystem/__init__.py index 83e4fcd..659930a 100644 --- a/src/iamsystem/__init__.py +++ b/src/iamsystem/__init__.py @@ -1,5 +1,3 @@ -__version__ = "0.4.0" - __all__ = [ "Matcher", "IMatcher", @@ -59,6 +57,8 @@ "TokenFormatter", ] +__annot_version__ = "0.4.0" + from iamsystem.brat.adapter import BratDocument from iamsystem.brat.adapter import BratEntity from iamsystem.brat.adapter import BratNote diff --git a/src/iamsystem/matcher/annotation.py b/src/iamsystem/matcher/annotation.py index b4a0ed2..5838f8e 100644 --- a/src/iamsystem/matcher/annotation.py +++ b/src/iamsystem/matcher/annotation.py @@ -127,7 +127,7 @@ def to_dict(self, text: str = None) -> Dict[str, Any]: if isinstance(keyword, IEntity) ], "kw_labels": [keyword.label for keyword in self.keywords], - "version": iamsystem.__version__, + "version": iamsystem.__annot_version__, } if text is not None: text_substring = text[self.start : self.end] # noqa From 8b244cec15a354e8da97b8df58136c4ec30ea2fd Mon Sep 17 00:00:00 2001 From: sebastien cossin Date: Sat, 11 Mar 2023 19:35:46 -0300 Subject: [PATCH 23/23] update package to version 0.4.0 --- CHANGELOG.md | 17 +++++++++++++++++ docs/source/conf.py | 2 +- pyproject.toml | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a3c39..9bbf50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # ChangeLog +## Version 0.4.0 (beta) + +### Breaking changes +- IAnnotation: remove 'brat_formatter' instance getter/setter. +'set_brat_formatter' becomes a class method to change the BratFormatter. +- Rename BratFormatters classes. +- Span (super class of Annotation): remove 'to_brat_format' method. +- Remove "offsets" attribute from the dictionary produced by +the 'to_dict' method of an annotation. +- FuzzyAlgo and ISynsProvider, get_synonyms method: change parameter name 'states' to 'transitions'. + +-### Enhancement +-- Bug fixes: 11 to 16. +-- Add the 'NoOverlap' matching strategy. +-- Add IAMsystem version to the 'to_dict' method of an annotation. + + ## Version 0.3.0 (beta) ### Breaking changes diff --git a/docs/source/conf.py b/docs/source/conf.py index 981b97c..0582ec2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -24,7 +24,7 @@ author = "Sebastien Cossin" # The full version, including alpha/beta/rc tags -release = "0.3.0" +release = "0.4.0" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 3a81a33..5a9355c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "iamsystem" -version = "0.3.0" +version = "0.4.0" authors = [ { name="Sebastien Cossin", email="cossin.sebastien@gmail.com" }, ]