Skip to content

Commit 66f55d0

Browse files
committed
Merge PR apache#1238 (OPENNLP-1920 relation extraction) into the gRPC helper base
2 parents e13a724 + 66f74f4 commit 66f55d0

8 files changed

Lines changed: 1445 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package opennlp.tools.relation;
19+
20+
import opennlp.tools.util.StringUtil;
21+
22+
/**
23+
* One typed relation between two entity mentions: the relation type and the positions of
24+
* the subject and object in the entity layer the relation was extracted from.
25+
*
26+
* <p>Entities are referenced by their index in the entity layer, following the
27+
* container's rule that annotations reference each other by layer and index, never by
28+
* object identity.</p>
29+
*
30+
* @param type The relation type, for example {@code acquisition}. Must not be
31+
* {@code null} or blank.
32+
* @param subject The index of the subject entity in the entity layer. Must not be
33+
* negative.
34+
* @param object The index of the object entity in the entity layer. Must not be
35+
* negative or equal to {@code subject}.
36+
*
37+
* @since 3.0.0
38+
*/
39+
public record RelationMention(String type, int subject, int object) {
40+
41+
/**
42+
* Validates the relation. Blankness follows {@link StringUtil#isBlank(CharSequence)},
43+
* the same definition {@code RelationPattern} judges a relation type by.
44+
*
45+
* @throws IllegalArgumentException Thrown if {@code type} is {@code null} or blank,
46+
* an index is negative, or {@code subject} equals {@code object}.
47+
*/
48+
public RelationMention {
49+
if (type == null || StringUtil.isBlank(type)) {
50+
throw new IllegalArgumentException("type must not be null or blank");
51+
}
52+
if (subject < 0 || object < 0) {
53+
throw new IllegalArgumentException(
54+
"entity indexes must not be negative: " + subject + ", " + object);
55+
}
56+
if (subject == object) {
57+
throw new IllegalArgumentException("subject and object must differ: " + subject);
58+
}
59+
}
60+
}
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package opennlp.tools.relation;
19+
20+
import java.util.ArrayList;
21+
import java.util.Collection;
22+
import java.util.List;
23+
import java.util.Set;
24+
25+
import opennlp.tools.depparse.DependencyAnnotator;
26+
import opennlp.tools.depparse.DependencyArc;
27+
import opennlp.tools.document.Annotation;
28+
import opennlp.tools.document.Document;
29+
import opennlp.tools.document.DocumentAnnotator;
30+
import opennlp.tools.document.DocumentAnnotators;
31+
import opennlp.tools.document.LayerKey;
32+
import opennlp.tools.document.Layers;
33+
import opennlp.tools.util.Span;
34+
import opennlp.tools.util.StringUtil;
35+
36+
/**
37+
* Extracts typed relations between entity pairs by matching {@link RelationPattern}
38+
* rules against the dependency path connecting the two entity heads, and provides
39+
* {@link #RELATIONS}, one annotation per relation carrying its {@link RelationMention}.
40+
*
41+
* <p>Each entity's head is the first token overlapping the entity span whose dependency
42+
* head lies outside the range of overlapping tokens. For every ordered entity pair the
43+
* annotator computes the path from the subject's head up to the lowest common ancestor
44+
* and down to the object's head, then emits one relation per pattern whose path shape
45+
* and trigger match. The annotation covers both entity spans; the mention references the
46+
* entities by their index in {@link Layers#ENTITIES}.</p>
47+
*
48+
* <p>The annotator holds no per-call state and is safe to share between threads.</p>
49+
*
50+
* @since 3.0.0
51+
*/
52+
public class RelationAnnotator implements DocumentAnnotator {
53+
54+
/**
55+
* Extracted relations; each annotation covers both entity spans and carries its
56+
* {@link RelationMention}.
57+
*/
58+
public static final LayerKey<RelationMention> RELATIONS =
59+
Layers.key("relations", RelationMention.class);
60+
61+
private final List<RelationPattern> patterns;
62+
private final List<List<String>> patternSteps;
63+
64+
/**
65+
* Initializes the annotator.
66+
*
67+
* @param patterns The rules to match. Must not be {@code null} or empty, and no rule
68+
* may be {@code null}.
69+
* @throws IllegalArgumentException Thrown if {@code patterns} is {@code null}, empty,
70+
* or contains {@code null}.
71+
*/
72+
public RelationAnnotator(Collection<RelationPattern> patterns) {
73+
if (patterns == null || patterns.isEmpty()) {
74+
throw new IllegalArgumentException("patterns must not be null or empty");
75+
}
76+
for (final RelationPattern pattern : patterns) {
77+
if (pattern == null) {
78+
throw new IllegalArgumentException("patterns must not contain null");
79+
}
80+
}
81+
this.patterns = List.copyOf(patterns);
82+
this.patternSteps = new ArrayList<>(this.patterns.size());
83+
for (final RelationPattern pattern : this.patterns) {
84+
patternSteps.add(pattern.steps());
85+
}
86+
}
87+
88+
/**
89+
* Matches every registered pattern against every ordered entity pair and adds the
90+
* {@link #RELATIONS} layer.
91+
*
92+
* <p>Pairs are visited in entity layer order and the patterns are applied in
93+
* registration order, so the extracted relations are in a stable order. A pair whose
94+
* entities share a head token, or whose heads are not connected in the dependency
95+
* graph, contributes no relation.</p>
96+
*
97+
* @param document The document to annotate. Must not be {@code null} and must carry
98+
* the {@link Layers#TOKENS}, {@link Layers#ENTITIES}, and
99+
* {@link DependencyAnnotator#DEPENDENCIES} layers, the dependency
100+
* layer holding exactly one arc per token. The layers may be empty: a
101+
* document without tokens or without entities yields an empty
102+
* {@link #RELATIONS} layer.
103+
* @return A new {@link Document} with the {@link #RELATIONS} layer added. Never
104+
* {@code null}.
105+
* @throws IllegalArgumentException Thrown if {@code document} is {@code null}, a
106+
* required layer is absent, the dependency layer does not hold exactly one
107+
* arc per token, two arcs share a dependent, an arc refers to a token
108+
* outside the token layer, or the document already carries the
109+
* {@link #RELATIONS} layer.
110+
*/
111+
@Override
112+
public Document annotate(Document document) {
113+
DocumentAnnotators.requireLayers(document, Layers.TOKENS, Layers.ENTITIES,
114+
DependencyAnnotator.DEPENDENCIES);
115+
final List<Annotation<String>> tokens = document.get(Layers.TOKENS);
116+
final List<Annotation<String>> entities = document.get(Layers.ENTITIES);
117+
final List<Annotation<DependencyArc>> arcs =
118+
document.get(DependencyAnnotator.DEPENDENCIES);
119+
if (arcs.size() != tokens.size()) {
120+
throw new IllegalArgumentException("document needs aligned " + Layers.TOKENS
121+
+ " and " + DependencyAnnotator.DEPENDENCIES + " layers");
122+
}
123+
124+
final int[] heads = new int[tokens.size()];
125+
final String[] relations = new String[tokens.size()];
126+
for (final Annotation<DependencyArc> arc : arcs) {
127+
final int dependent = arc.value().dependent();
128+
if (dependent >= tokens.size() || arc.value().head() >= tokens.size()
129+
|| relations[dependent] != null) {
130+
throw new IllegalArgumentException(
131+
"dependency layer is not aligned with the token layer at " + dependent);
132+
}
133+
heads[dependent] = arc.value().head();
134+
relations[dependent] = arc.value().relation();
135+
}
136+
137+
// Each entity's chain to the root depends only on that entity, so walking it once
138+
// per entity keeps the pair loop below from repeating the walk for every partner.
139+
final int[] entityHeads = new int[entities.size()];
140+
final int[][] chains = new int[entities.size()][];
141+
for (int e = 0; e < entities.size(); e++) {
142+
entityHeads[e] = entityHead(entities.get(e).span(), tokens, heads);
143+
chains[e] = entityHeads[e] < 0 ? null : chainToRoot(entityHeads[e], heads);
144+
}
145+
146+
final List<Annotation<RelationMention>> mentions = new ArrayList<>();
147+
for (int subject = 0; subject < entities.size(); subject++) {
148+
for (int object = 0; object < entities.size(); object++) {
149+
if (subject == object || entityHeads[subject] == entityHeads[object]
150+
|| chains[subject] == null || chains[object] == null) {
151+
continue;
152+
}
153+
matchPair(tokens, relations, entities, subject, object,
154+
chains[subject], chains[object], mentions);
155+
}
156+
}
157+
return document.with(RELATIONS, mentions);
158+
}
159+
160+
@Override
161+
public Set<LayerKey<?>> requires() {
162+
return Set.of(Layers.TOKENS, Layers.ENTITIES, DependencyAnnotator.DEPENDENCIES);
163+
}
164+
165+
@Override
166+
public Set<LayerKey<?>> provides() {
167+
return Set.of(RELATIONS);
168+
}
169+
170+
/**
171+
* Matches all patterns against one ordered entity pair and collects the resulting
172+
* relations. The pair contributes one relation per matching pattern, and nothing when
173+
* the two chains do not meet.
174+
*
175+
* @param tokens The token layer.
176+
* @param relations The relation label of each token's arc to its dependency head,
177+
* indexed by dependent token.
178+
* @param entities The entity layer.
179+
* @param subject The subject entity index.
180+
* @param object The object entity index.
181+
* @param subjectChain The chain from the subject's head token to the root.
182+
* @param objectChain The chain from the object's head token to the root.
183+
* @param mentions The list that receives one annotation per matching pattern.
184+
*/
185+
private void matchPair(List<Annotation<String>> tokens,
186+
String[] relations, List<Annotation<String>> entities,
187+
int subject, int object,
188+
int[] subjectChain, int[] objectChain,
189+
List<Annotation<RelationMention>> mentions) {
190+
int pivotOnSubject = -1;
191+
int pivotOnObject = -1;
192+
for (int o = 0; o < objectChain.length && pivotOnSubject < 0; o++) {
193+
for (int s = 0; s < subjectChain.length; s++) {
194+
if (subjectChain[s] == objectChain[o]) {
195+
pivotOnSubject = s;
196+
pivotOnObject = o;
197+
break;
198+
}
199+
}
200+
}
201+
if (pivotOnSubject < 0) {
202+
return;
203+
}
204+
205+
final List<String> steps = new ArrayList<>();
206+
for (int s = 0; s < pivotOnSubject; s++) {
207+
steps.add(RelationPattern.UP_STEP + relations[subjectChain[s]]);
208+
}
209+
for (int o = pivotOnObject - 1; o >= 0; o--) {
210+
steps.add(RelationPattern.DOWN_STEP + relations[objectChain[o]]);
211+
}
212+
final int pivot = subjectChain[pivotOnSubject];
213+
final String pivotForm = StringUtil.toLowerCase(tokens.get(pivot).value());
214+
215+
for (int p = 0; p < patterns.size(); p++) {
216+
final RelationPattern pattern = patterns.get(p);
217+
if (patternSteps.get(p).equals(steps)
218+
&& (pattern.trigger() == null || pattern.trigger().equals(pivotForm))) {
219+
final Span subjectSpan = entities.get(subject).span();
220+
final Span objectSpan = entities.get(object).span();
221+
final Span covering = new Span(
222+
Math.min(subjectSpan.getStart(), objectSpan.getStart()),
223+
Math.max(subjectSpan.getEnd(), objectSpan.getEnd()));
224+
mentions.add(new Annotation<>(covering,
225+
new RelationMention(pattern.type(), subject, object)));
226+
}
227+
}
228+
}
229+
230+
/**
231+
* Finds the head token of an entity: the first token overlapping the entity span
232+
* whose dependency head lies outside the index range of the overlapping tokens. A
233+
* token overlaps the entity when their spans share at least one character. When no
234+
* overlapping token is headed outside that range, which only cyclic arcs inside the
235+
* range can cause, the first overlapping token is used as a fallback.
236+
*
237+
* @param entity The entity span in text coordinates.
238+
* @param tokens The token layer.
239+
* @param heads The dependency head of each token, indexed by dependent token.
240+
* @return The head token index, or {@code -1} if no token overlaps the entity.
241+
*/
242+
private int entityHead(Span entity, List<Annotation<String>> tokens, int[] heads) {
243+
int first = -1;
244+
int last = -1;
245+
for (int t = 0; t < tokens.size(); t++) {
246+
final Span span = tokens.get(t).span();
247+
if (span.getStart() < entity.getEnd() && span.getEnd() > entity.getStart()) {
248+
if (first < 0) {
249+
first = t;
250+
}
251+
last = t;
252+
}
253+
}
254+
if (first < 0) {
255+
return -1;
256+
}
257+
for (int t = first; t <= last; t++) {
258+
if (heads[t] < first || heads[t] > last) {
259+
return t;
260+
}
261+
}
262+
return first;
263+
}
264+
265+
/**
266+
* Walks from a token to the root, collecting the visited tokens in order.
267+
*
268+
* @param start The token to start from.
269+
* @param heads The dependency head of each token, indexed by dependent token.
270+
* @return The chain including {@code start} and ending at the root token, or
271+
* {@code null} when the walk takes more steps than there are tokens, which
272+
* only happens when the arcs contain a cycle.
273+
*/
274+
private int[] chainToRoot(int start, int[] heads) {
275+
int length = 0;
276+
for (int current = start; current != DependencyArc.ROOT_HEAD; current = heads[current]) {
277+
if (++length > heads.length) {
278+
return null;
279+
}
280+
}
281+
final int[] chain = new int[length];
282+
int current = start;
283+
for (int i = 0; i < length; i++) {
284+
chain[i] = current;
285+
current = heads[current];
286+
}
287+
return chain;
288+
}
289+
290+
}

0 commit comments

Comments
 (0)