pyprocessors-coreference 1.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyprocessors_coreference/__init__.py +3 -0
- pyprocessors_coreference/coreference.py +174 -0
- pyprocessors_coreference-1.6.1.dist-info/METADATA +131 -0
- pyprocessors_coreference-1.6.1.dist-info/RECORD +6 -0
- pyprocessors_coreference-1.6.1.dist-info/WHEEL +4 -0
- pyprocessors_coreference-1.6.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import unicodedata
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
from pymultirole_plugins.v1.processor import ProcessorBase, ProcessorParameters
|
|
8
|
+
from pymultirole_plugins.v1.schema import Annotation, Document
|
|
9
|
+
|
|
10
|
+
# Honorifics in front of a person name, stripped before comparing names: in AFP
|
|
11
|
+
# dispatches "M. Macron" follows "Emmanuel Macron". Compared case-insensitively.
|
|
12
|
+
DEFAULT_HONORIFICS = ["M.", "MM.", "Mme", "Mmes", "Mlle", "Mlles", "Me", "Dr", "Dr.", "Pr", "Pr.", "Mr", "Mr.", "Mrs", "Mrs.", "Ms", "Ms.", "Miss", "Sir", "Dame", "Lord", "Lady"]
|
|
13
|
+
|
|
14
|
+
# Properties written on every grouped annotation (ADR-0001, §5).
|
|
15
|
+
ENTITY_GROUP = "entity_group"
|
|
16
|
+
CANONICAL_FORM = "canonical_form"
|
|
17
|
+
MENTION_TYPE = "mention_type"
|
|
18
|
+
# Only on an ambiguous partial name: the groups it may belong to.
|
|
19
|
+
ENTITY_GROUP_CANDIDATES = "entity_group_candidates"
|
|
20
|
+
|
|
21
|
+
# The family of every person label: the NER's "person" and the AFP lexicon's
|
|
22
|
+
# "afpperson" annotate the same span, and must land in the same group.
|
|
23
|
+
PERSON_FAMILY = "\x00person"
|
|
24
|
+
|
|
25
|
+
APOSTROPHES = str.maketrans({"’": "'", "‘": "'", "ʼ": "'"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CoreferenceParameters(ProcessorParameters):
|
|
29
|
+
"""Parameters of the coreference processor"""
|
|
30
|
+
|
|
31
|
+
labels: list[str] | None = Field(
|
|
32
|
+
None,
|
|
33
|
+
description="""Labels of the mentions to group. If empty, the mentions of every label are grouped.<br/>
|
|
34
|
+
Mentions are only grouped with mentions of the same label, except person labels, which all form one family.""",
|
|
35
|
+
json_schema_extra={"extra": "label"},
|
|
36
|
+
)
|
|
37
|
+
person_labels: list[str] | None = Field(
|
|
38
|
+
None,
|
|
39
|
+
description="""Labels of person names. They form one family (person and afpperson mentions are grouped together),
|
|
40
|
+
and a partial name ("Macron") joins the full name it ends ("Emmanuel Macron").<br/>
|
|
41
|
+
If empty, every label whose name contains "person" (person, afpperson...).""",
|
|
42
|
+
json_schema_extra={"extra": "label"},
|
|
43
|
+
)
|
|
44
|
+
honorifics: list[str] = Field(
|
|
45
|
+
DEFAULT_HONORIFICS,
|
|
46
|
+
description="Honorifics ignored in front of a person name when comparing names (M., Mme, Dr...)",
|
|
47
|
+
json_schema_extra={"extra": "advanced"},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CoreferenceProcessor(ProcessorBase):
|
|
52
|
+
"""Group the mentions of the same entity, before entity linking.
|
|
53
|
+
|
|
54
|
+
Tier 1 of the mention grouping of ADR-0001 (pyannotators_entityfishing): names
|
|
55
|
+
only. Every annotation of a grouped label gets an `entity_group` (the same id for
|
|
56
|
+
every mention of the entity in the document) and a `canonical_form` (the longest
|
|
57
|
+
form of the group, which the candidate generation looks up):
|
|
58
|
+
|
|
59
|
+
- same normalized surface and same label → same group; all person labels count
|
|
60
|
+
as one (the NER's "person" and the AFP lexicon's "afpperson" annotate the same
|
|
61
|
+
span);
|
|
62
|
+
- person labels only: a partial name joins the full name it ends, wherever the
|
|
63
|
+
full name is in the document ("Macron" joins "Emmanuel Macron");
|
|
64
|
+
- a first name alone never joins ("Emmanuel" stays on its own);
|
|
65
|
+
- an ambiguous partial name ("Macron" with both Emmanuel and Brigitte Macron)
|
|
66
|
+
keeps a group of its own, and lists the possible groups in
|
|
67
|
+
`entity_group_candidates` for the linking step to decide.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def process(self, documents: list[Document], parameters: ProcessorParameters) -> list[Document]:
|
|
71
|
+
params = cast(CoreferenceParameters, parameters)
|
|
72
|
+
honorifics = {h.casefold() for h in params.honorifics}
|
|
73
|
+
for document in documents:
|
|
74
|
+
group_document(document, params, honorifics)
|
|
75
|
+
return documents
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def get_model(cls) -> type[BaseModel]:
|
|
79
|
+
return CoreferenceParameters
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def group_document(document: Document, params: CoreferenceParameters, honorifics: set[str]) -> None:
|
|
83
|
+
text = document.text or ""
|
|
84
|
+
mentions = [a for a in (document.annotations or []) if a.labelName and (not params.labels or a.labelName in params.labels)]
|
|
85
|
+
if not mentions:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# 1. Same label, same normalized surface: one surface group.
|
|
89
|
+
surfaces: dict[tuple[str, tuple[str, ...]], list[Annotation]] = defaultdict(list)
|
|
90
|
+
for a in sorted(mentions, key=lambda a: (a.start, a.end)):
|
|
91
|
+
person = is_person_label(a.labelName, params)
|
|
92
|
+
tokens = name_tokens(surface_of(a, text), honorifics if person else set())
|
|
93
|
+
if tokens:
|
|
94
|
+
surfaces[(PERSON_FAMILY if person else a.labelName, tokens)].append(a)
|
|
95
|
+
|
|
96
|
+
# 2. Person labels: a surface that is a proper suffix of longer surfaces joins
|
|
97
|
+
# them when they are all one entity. Longest first, so that "Emmanuel Macron" has
|
|
98
|
+
# already joined "Président Emmanuel Macron" when "Macron" is considered.
|
|
99
|
+
parent = {key: key for key in surfaces}
|
|
100
|
+
|
|
101
|
+
def root(key):
|
|
102
|
+
while parent[key] != key:
|
|
103
|
+
parent[key] = parent[parent[key]]
|
|
104
|
+
key = parent[key]
|
|
105
|
+
return key
|
|
106
|
+
|
|
107
|
+
ambiguous: dict[tuple, set] = {}
|
|
108
|
+
for key in sorted(surfaces, key=lambda k: -len(k[1])):
|
|
109
|
+
family, tokens = key
|
|
110
|
+
if family != PERSON_FAMILY:
|
|
111
|
+
continue
|
|
112
|
+
longer = {root(other) for other in surfaces if other[0] == PERSON_FAMILY and is_proper_suffix(tokens, other[1])}
|
|
113
|
+
longer.discard(root(key))
|
|
114
|
+
if len(longer) == 1:
|
|
115
|
+
parent[root(key)] = longer.pop()
|
|
116
|
+
elif longer:
|
|
117
|
+
ambiguous[key] = longer
|
|
118
|
+
|
|
119
|
+
# 3. Ids in order of first appearance, canonical form = longest mention of the group.
|
|
120
|
+
members: dict[tuple, list[Annotation]] = defaultdict(list)
|
|
121
|
+
for key, annotations in surfaces.items():
|
|
122
|
+
members[root(key)].extend(annotations)
|
|
123
|
+
ordered = sorted(members, key=lambda r: min(a.start for a in members[r]))
|
|
124
|
+
group_id = {r: f"g{i}" for i, r in enumerate(ordered, start=1)}
|
|
125
|
+
canonical = {r: canonical_form(members[r], text, honorifics if r[0] == PERSON_FAMILY else set()) for r in ordered}
|
|
126
|
+
|
|
127
|
+
for key, annotations in surfaces.items():
|
|
128
|
+
r = root(key)
|
|
129
|
+
for a in annotations:
|
|
130
|
+
properties = dict(a.properties or {})
|
|
131
|
+
properties[ENTITY_GROUP] = group_id[r]
|
|
132
|
+
properties[CANONICAL_FORM] = canonical[r]
|
|
133
|
+
properties[MENTION_TYPE] = "name"
|
|
134
|
+
if key in ambiguous:
|
|
135
|
+
properties[ENTITY_GROUP_CANDIDATES] = sorted((group_id[c] for c in ambiguous[key]), key=lambda g: int(g[1:]))
|
|
136
|
+
else:
|
|
137
|
+
properties.pop(ENTITY_GROUP_CANDIDATES, None)
|
|
138
|
+
a.properties = properties
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_person_label(label: str, params: CoreferenceParameters) -> bool:
|
|
142
|
+
if params.person_labels:
|
|
143
|
+
return label in params.person_labels
|
|
144
|
+
return "person" in label.casefold()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def surface_of(a: Annotation, text: str) -> str:
|
|
148
|
+
return a.text if a.text is not None else text[a.start : a.end]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def name_tokens(surface: str, honorifics: set[str]) -> tuple[str, ...]:
|
|
152
|
+
"""Normalized tokens of a surface: NFKC, one apostrophe, case-folded, and the
|
|
153
|
+
leading honorifics dropped (only for person names, where the set is not empty)."""
|
|
154
|
+
tokens = unicodedata.normalize("NFKC", surface).translate(APOSTROPHES).casefold().split()
|
|
155
|
+
while len(tokens) > 1 and tokens[0] in honorifics:
|
|
156
|
+
tokens = tokens[1:]
|
|
157
|
+
return tuple(tokens)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def strip_honorifics(surface: str, honorifics: set[str]) -> str:
|
|
161
|
+
words = re.sub(r"\s+", " ", surface).strip().split(" ")
|
|
162
|
+
while len(words) > 1 and words[0].casefold() in honorifics:
|
|
163
|
+
words = words[1:]
|
|
164
|
+
return " ".join(words)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_proper_suffix(tokens: tuple[str, ...], other: tuple[str, ...]) -> bool:
|
|
168
|
+
return len(tokens) < len(other) and other[len(other) - len(tokens) :] == tokens
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def canonical_form(annotations: list[Annotation], text: str, honorifics: set[str]) -> str:
|
|
172
|
+
"""The longest form of the group: most words, then most characters, then the first."""
|
|
173
|
+
forms = [strip_honorifics(surface_of(a, text), honorifics) for a in sorted(annotations, key=lambda a: a.start)]
|
|
174
|
+
return max(forms, key=lambda f: (len(f.split()), len(f)))
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyprocessors-coreference
|
|
3
|
+
Version: 1.6.1
|
|
4
|
+
Summary: Processor grouping the mentions of the same entity
|
|
5
|
+
Project-URL: Homepage, https://bitbucket.org/kairntech/pyprocessors_coreference
|
|
6
|
+
Author-email: Olivier Terrier <olivier.terrier@kairntech.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Information Technology
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: pymultirole-plugins<1.7.0,>=1.6.0
|
|
21
|
+
Provides-Extra: sbom
|
|
22
|
+
Requires-Dist: cyclonedx-bom; extra == 'sbom'
|
|
23
|
+
Requires-Dist: pip-audit; extra == 'sbom'
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pip; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest; extra == 'test'
|
|
27
|
+
Requires-Dist: ruff; extra == 'test'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# pyprocessors-coreference
|
|
31
|
+
|
|
32
|
+
Processor grouping the mentions of the same entity.
|
|
33
|
+
|
|
34
|
+
Ce dépôt enregistre un processeur dans le groupe d'entry-points `pyprocessors.plugins` :
|
|
35
|
+
|
|
36
|
+
| Plugin | Classe |
|
|
37
|
+
|--------|--------|
|
|
38
|
+
| `coreference` | `pyprocessors_coreference.coreference:CoreferenceProcessor` |
|
|
39
|
+
|
|
40
|
+
Il regroupe les mentions d'une même entité **avant** le linking, pour qu'elles soient
|
|
41
|
+
liées une seule fois, à partir de leur forme la plus longue. C'est l'étape de
|
|
42
|
+
regroupement des mentions de l'ADR-0001 de `pyannotators_entityfishing`
|
|
43
|
+
([`docs/adr/`](docs/adr/0001-linking-candidats-entityfishing-decision-jev.md)), palier 1 :
|
|
44
|
+
les noms seulement.
|
|
45
|
+
|
|
46
|
+
## Ce qu'il fait
|
|
47
|
+
|
|
48
|
+
Pour chaque document, entre mentions de même label :
|
|
49
|
+
|
|
50
|
+
- **même surface normalisée → même groupe.** La normalisation ignore la casse, les
|
|
51
|
+
espaces, le style des apostrophes, et les civilités en tête d'un nom de personne
|
|
52
|
+
(« M. », « Mme », « Dr »…) ;
|
|
53
|
+
- **tous les labels de personne forment une seule famille** : le `person` du NER et
|
|
54
|
+
l'`afpperson` des lexiques AFP annotent le même span, et vont dans le même groupe ;
|
|
55
|
+
- **un nom partiel de personne rejoint le nom complet dont il est la fin**, que le
|
|
56
|
+
nom complet soit avant ou après lui dans le document : « Macron » et « M. Macron »
|
|
57
|
+
rejoignent « Emmanuel Macron » ;
|
|
58
|
+
- **un prénom seul ne rejoint jamais un nom complet** : « Emmanuel » reste à part ;
|
|
59
|
+
- **un nom partiel ambigu n'est pas fusionné** : avec « Emmanuel Macron » et
|
|
60
|
+
« Brigitte Macron » dans le texte, « Macron » garde son propre groupe, et liste les
|
|
61
|
+
groupes possibles pour que l'étape de linking tranche ;
|
|
62
|
+
- **la règle du nom partiel ne vaut que pour les personnes** : « Airbus » ne rejoint pas
|
|
63
|
+
« Airbus Defence and Space ».
|
|
64
|
+
|
|
65
|
+
Sur chaque annotation d'un label regroupé, il écrit dans `properties` :
|
|
66
|
+
|
|
67
|
+
| Propriété | Valeur |
|
|
68
|
+
|-----------|--------|
|
|
69
|
+
| `entity_group` | l'identifiant du groupe dans le document : `g1`, `g2`… dans l'ordre de première apparition |
|
|
70
|
+
| `canonical_form` | la forme la plus longue du groupe, sans civilité : c'est elle qu'on cherche dans la base de connaissances |
|
|
71
|
+
| `mention_type` | `name` (les périphrases et les pronoms, `nominal` et `pronoun`, viendront avec les paliers 2 et 3) |
|
|
72
|
+
| `entity_group_candidates` | seulement sur un nom partiel ambigu : les groupes auxquels il peut appartenir |
|
|
73
|
+
|
|
74
|
+
Les autres propriétés de l'annotation sont gardées, et traiter deux fois un document
|
|
75
|
+
donne le même résultat. La portée est le document entier, ce qui convient à une
|
|
76
|
+
dépêche ; les documents longs sont une question ouverte (SHERPA-3048).
|
|
77
|
+
|
|
78
|
+
## Requirements
|
|
79
|
+
|
|
80
|
+
- Python 3.12+
|
|
81
|
+
- `pymultirole_plugins` (>=1.6.0,<1.7.0)
|
|
82
|
+
|
|
83
|
+
## Installation
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
pip install pyprocessors-coreference
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pymultirole_plugins.v1.schema import Annotation, Document
|
|
93
|
+
|
|
94
|
+
from pyprocessors_coreference.coreference import CoreferenceParameters, CoreferenceProcessor
|
|
95
|
+
|
|
96
|
+
text = "Emmanuel Macron est arrivé. M. Macron a parlé."
|
|
97
|
+
document = Document(
|
|
98
|
+
text=text,
|
|
99
|
+
annotations=[
|
|
100
|
+
Annotation(start=0, end=15, labelName="person", text="Emmanuel Macron"),
|
|
101
|
+
Annotation(start=28, end=37, labelName="person", text="M. Macron"),
|
|
102
|
+
],
|
|
103
|
+
)
|
|
104
|
+
[document] = CoreferenceProcessor().process([document], CoreferenceParameters())
|
|
105
|
+
# Both annotations: entity_group "g1", canonical_form "Emmanuel Macron".
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Parameters
|
|
109
|
+
|
|
110
|
+
| Paramètre | Défaut | Description |
|
|
111
|
+
|-----------|--------|-------------|
|
|
112
|
+
| `labels` | vide | Labels des mentions à regrouper. Vide : tous les labels. |
|
|
113
|
+
| `person_labels` | vide | Labels de personne : ils forment une seule famille, et la règle du nom partiel s'y applique. Vide : tout label dont le nom contient « person » (`person`, `afpperson`…). |
|
|
114
|
+
| `honorifics` | `M.`, `Mme`, `Dr`, `Mr`… | Civilités ignorées en tête d'un nom de personne. Paramètre avancé. |
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
Le build est piloté par [Task](https://taskfile.dev) et [uv](https://docs.astral.sh/uv/),
|
|
119
|
+
les stages venant du submodule `python-archetype` :
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
git submodule update --init
|
|
123
|
+
task stages # les stages du pipeline, dans l'ordre
|
|
124
|
+
task # tout sauf le dernier stage — donc tout sauf la publication
|
|
125
|
+
task -- --skip-tests # idem, sans le stage de test
|
|
126
|
+
task up-to -- py:lint
|
|
127
|
+
task jenkins # tout, exactement ce que joue Jenkins
|
|
128
|
+
task --list
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`uv.lock` n'est pas versionné ici, donc `py:sync` résout les dépendances à neuf.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pyprocessors_coreference/__init__.py,sha256=FhyAPdjVtDKqUrcvJaY5vUCeqGcWK2UlYYOf3nQ_yY0,80
|
|
2
|
+
pyprocessors_coreference/coreference.py,sha256=4rENXHvwliOCRMuO9BS-K9WUlLE55YaDGby-SDcgipI,7980
|
|
3
|
+
pyprocessors_coreference-1.6.1.dist-info/METADATA,sha256=7TrpLF88egi26V1467WL1RrYo_moKqGcrehg0Le2w7k,5660
|
|
4
|
+
pyprocessors_coreference-1.6.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
pyprocessors_coreference-1.6.1.dist-info/entry_points.txt,sha256=Ko1I0ZLLs3U0D2GDEZ_kzHN6OfsAjpwJNb7BCpWL-J4,95
|
|
6
|
+
pyprocessors_coreference-1.6.1.dist-info/RECORD,,
|