Skip to content

Commit be0e761

Browse files
committed
Merge PR #1167 head into OPENNLP-1833-grpc-helper
# Conflicts: # opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java # opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
2 parents f2174d2 + fa0d253 commit be0e761

11 files changed

Lines changed: 2035 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.claude
12
*.iml
23
.idea
34
target

opennlp-docs/src/docbkx/wordnet.xml

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
of speech. Two readers are provided: <code>WnLmfReader</code> for the
2626
Global WordNet Association WN-LMF XML interchange format, and
2727
<code>WndbReader</code> for the classic Princeton WordNet database file
28-
layout. Both return an immutable, thread-safe knowledge base.
28+
layout. Both return an immutable, thread-safe knowledge base. On top of
29+
lookup, the module can Morphy-lemmatize, expand a term through synonym and
30+
hypernym links, score synset similarity on the hypernym graph, and type
31+
nouns by their nearest anchored hypernym.
2932
</para>
3033
</section>
3134

@@ -103,4 +106,85 @@ lemmatizer.lemmatize(new String[] {"dogs"}, new String[] {"NNS"})[0]; // "dog"]
103106
</programlisting>
104107
</para>
105108
</section>
109+
110+
<section xml:id="tools.wordnet.expansion">
111+
<title>Lexical expansion</title>
112+
<para>
113+
<code>LexicalExpander</code> turns a term into related terms from the
114+
knowledge base: synonyms sharing its synsets, hypernym ancestors up to a
115+
configured depth, and optionally direct hyponyms. Each
116+
<code>Expansion</code> carries a heuristic weight in
117+
<code>(0, 1]</code>: the first sense starts at <code>1.0</code>, later
118+
senses multiply by the sense decay, and each hypernym or hyponym step
119+
multiplies by the depth decay. The input term itself is never returned.
120+
Defaults use depth <code>1</code>, sense decay <code>0.5</code>, and
121+
depth decay <code>0.5</code>. The example runs against the two-sense
122+
miniature taxonomy in the leading comment, not the lookup fixture above:
123+
the mini fixture carries only one sense of <code>dog</code>, so it cannot
124+
show the sense decay. <code>LexicalExpansionUsageExampleTest</code>
125+
builds exactly that taxonomy and asserts the behavior shown here.
126+
<programlisting language="java"><![CDATA[
127+
// dog sense 1 = {dog, domestic dog}, hypernym {canid}
128+
// dog sense 2 = {dog, frank, hot dog}, hypernym {sausage}
129+
List<Expansion> expansions = LexicalExpander.builder(lexicon)
130+
.build()
131+
.expand("dog", WordNetPOS.NOUN);
132+
133+
// "domestic dog": SYNONYM, senseRank 0, weight 1.0
134+
// "canid": HYPERNYM, depth 1, weight 0.5
135+
// "frank": SYNONYM, senseRank 1, weight 0.5]]>
136+
</programlisting>
137+
</para>
138+
</section>
139+
140+
<section xml:id="tools.wordnet.similarity">
141+
<title>Synset similarity</title>
142+
<para>
143+
<code>SynsetSimilarity</code> scores two synset identifiers on the
144+
hypernym graph. Path similarity is <code>1 / (1 + d)</code> for the
145+
shortest distance <code>d</code> through a common ancestor. Wu-Palmer
146+
similarity relates the depth of the deepest common ancestor to the depths
147+
of both synsets, with depths counted in nodes so the root sits at depth
148+
one and any shared ancestor scores above <code>0</code>. Only unrelated
149+
synsets score <code>0</code>. The example runs
150+
against the miniature taxonomy in the leading comment, with synset ids
151+
<code>n1</code> to <code>n9</code>;
152+
<code>LexicalExpansionUsageExampleTest</code> builds exactly that
153+
taxonomy and asserts the behavior shown here.
154+
<programlisting language="java"><![CDATA[
155+
// entity (n1) > physical (n2) > organism (n3) > person (n4)
156+
// > scientist (n5) > chemist (n6)
157+
// physical (n2) > location (n7) > city (n8)
158+
// paris (n9) is an instance of city
159+
SynsetSimilarity similarity = new SynsetSimilarity(lexicon);
160+
161+
similarity.path("n6", "n5"); // 0.5 (chemist to scientist)
162+
similarity.wuPalmer("n5", "n6"); // 10.0/11.0 (deep shared ancestry)
163+
similarity.path("n6", "n8"); // 1.0/7.0 (chemist to city, six edges)]]>
164+
</programlisting>
165+
</para>
166+
</section>
167+
168+
<section xml:id="tools.wordnet.typing">
169+
<title>Hypernym-anchored typing</title>
170+
<para>
171+
<code>HypernymTyper</code> types a noun by walking its hypernym chain to
172+
the nearest registered anchor: the caller maps anchor lemmas to the
173+
labels they confer, and any noun whose senses lead up to an anchor
174+
receives that anchor's label. Instance hypernyms count, so named
175+
entities reach their class anchors. The walk is upward only, and a noun
176+
reaching no anchor gets no type. The example runs against the taxonomy
177+
of the previous section; <code>HypernymTyperTest</code> and
178+
<code>LexicalExpansionUsageExampleTest</code> assert the behavior shown
179+
here.
180+
<programlisting language="java"><![CDATA[
181+
HypernymTyper typer = new HypernymTyper(lexicon,
182+
Map.of("person", "person", "location", "location"));
183+
184+
typer.type("chemist"); // Optional[person]
185+
typer.type("paris"); // Optional[location]
186+
typer.type("organism"); // Optional.empty, the walk is upward only]]>
187+
</programlisting>
188+
</para>
189+
</section>
106190
</chapter>
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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.wordnet;
19+
20+
import java.util.ArrayDeque;
21+
import java.util.Deque;
22+
import java.util.HashMap;
23+
import java.util.LinkedHashMap;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.Optional;
27+
28+
import opennlp.tools.commons.ThreadSafe;
29+
import opennlp.tools.util.StringUtil;
30+
import opennlp.tools.wordnet.LexicalKnowledgeBase;
31+
import opennlp.tools.wordnet.Synset;
32+
import opennlp.tools.wordnet.WordNetPOS;
33+
import opennlp.tools.wordnet.WordNetRelation;
34+
35+
/**
36+
* Types a noun by walking its hypernym chain to the nearest registered anchor: the
37+
* caller names anchor concepts by lemma, {@code person}, {@code organization},
38+
* {@code location}, and any word whose senses lead up to an anchor's synsets receives
39+
* that anchor's label. The nearest anchor wins, so a more specific registered concept
40+
* beats a general one.
41+
*
42+
* <p>Anchors are resolved against the knowledge base at construction and follow its
43+
* sense inventory; nothing beyond the caller's anchor choice is built in. Words with no
44+
* sense reaching an anchor get no type.</p>
45+
*
46+
* <p>The typer reads only immutable state and is safe to share between threads.</p>
47+
*/
48+
@ThreadSafe
49+
public class HypernymTyper {
50+
51+
/** The relations that lead from a synset to its generalizations. */
52+
private static final List<WordNetRelation> UPWARD_RELATIONS =
53+
List.of(WordNetRelation.HYPERNYM, WordNetRelation.INSTANCE_HYPERNYM);
54+
55+
private final LexicalKnowledgeBase knowledgeBase;
56+
private final Map<String, String> labelBySynsetId;
57+
58+
/**
59+
* Initializes the typer.
60+
*
61+
* @param knowledgeBase The knowledge base to walk. Must not be {@code null}.
62+
* @param anchors The anchor lemmas mapped to the labels they confer, for example
63+
* {@code person} to {@code person}. Every lemma is resolved as a noun;
64+
* all its senses anchor. Must not be {@code null} or empty, and no
65+
* lemma or label may be blank.
66+
* @throws IllegalArgumentException Thrown if a parameter is {@code null},
67+
* {@code anchors} is empty or holds a blank entry, or an anchor lemma is
68+
* unknown to the knowledge base.
69+
*/
70+
public HypernymTyper(LexicalKnowledgeBase knowledgeBase, Map<String, String> anchors) {
71+
if (knowledgeBase == null) {
72+
throw new IllegalArgumentException("knowledgeBase must not be null");
73+
}
74+
if (anchors == null || anchors.isEmpty()) {
75+
throw new IllegalArgumentException("anchors must not be null or empty");
76+
}
77+
this.knowledgeBase = knowledgeBase;
78+
final Map<String, String> labels = new LinkedHashMap<>();
79+
for (final Map.Entry<String, String> anchor : anchors.entrySet()) {
80+
if (anchor.getKey() == null || StringUtil.isBlank(anchor.getKey())
81+
|| anchor.getValue() == null || StringUtil.isBlank(anchor.getValue())) {
82+
throw new IllegalArgumentException("anchors must not contain blank entries");
83+
}
84+
final List<Synset> senses = knowledgeBase.lookup(anchor.getKey(), WordNetPOS.NOUN);
85+
if (senses.isEmpty()) {
86+
throw new IllegalArgumentException(
87+
"anchor lemma is unknown to the knowledge base: " + anchor.getKey());
88+
}
89+
for (final Synset sense : senses) {
90+
labels.putIfAbsent(sense.id(), anchor.getValue());
91+
}
92+
}
93+
this.labelBySynsetId = Map.copyOf(labels);
94+
}
95+
96+
/**
97+
* Types a noun by its nearest anchored hypernym.
98+
*
99+
* @param lemma The noun lemma to type. Must not be {@code null} or blank.
100+
* @return The label of the nearest anchor over all senses, or empty when no sense
101+
* reaches an anchor.
102+
* @throws IllegalArgumentException Thrown if {@code lemma} is {@code null} or blank.
103+
*/
104+
public Optional<String> type(String lemma) {
105+
if (lemma == null || StringUtil.isBlank(lemma)) {
106+
throw new IllegalArgumentException("lemma must not be null or blank");
107+
}
108+
String bestLabel = null;
109+
int bestDistance = Integer.MAX_VALUE;
110+
for (final Synset sense : knowledgeBase.lookup(lemma, WordNetPOS.NOUN)) {
111+
final int[] distance = new int[1];
112+
final String label = nearestAnchor(sense.id(), distance);
113+
if (label != null && distance[0] < bestDistance) {
114+
bestDistance = distance[0];
115+
bestLabel = label;
116+
}
117+
}
118+
return Optional.ofNullable(bestLabel);
119+
}
120+
121+
/**
122+
* Types a specific synset by its nearest anchored hypernym.
123+
*
124+
* @param synsetId The synset identifier. Must not be {@code null}.
125+
* @return The nearest anchor's label, or empty when no ancestor is anchored.
126+
* @throws IllegalArgumentException Thrown if {@code synsetId} is {@code null}.
127+
*/
128+
public Optional<String> typeSynset(String synsetId) {
129+
if (synsetId == null) {
130+
throw new IllegalArgumentException("synsetId must not be null");
131+
}
132+
return Optional.ofNullable(nearestAnchor(synsetId, new int[1]));
133+
}
134+
135+
/**
136+
* Walks up the hypernym graph breadth first to the closest anchored synset. Visiting each
137+
* synset once bounds the walk even on cyclic data.
138+
*
139+
* @param synsetId The synset to start from. Must not be {@code null}.
140+
* @param distanceOut A single-element array that receives the edge count to the anchor found;
141+
* left untouched when no ancestor is anchored.
142+
* @return The label of the nearest anchored synset, or {@code null} when none is reachable.
143+
*/
144+
private String nearestAnchor(String synsetId, int[] distanceOut) {
145+
final Deque<String> queue = new ArrayDeque<>();
146+
final Map<String, Integer> depths = new HashMap<>();
147+
queue.add(synsetId);
148+
depths.put(synsetId, 0);
149+
while (!queue.isEmpty()) {
150+
final String current = queue.remove();
151+
final String label = labelBySynsetId.get(current);
152+
if (label != null) {
153+
distanceOut[0] = depths.get(current);
154+
return label;
155+
}
156+
final int parentDepth = depths.get(current) + 1;
157+
for (final WordNetRelation relation : UPWARD_RELATIONS) {
158+
for (final String parent : knowledgeBase.related(current, relation)) {
159+
if (depths.putIfAbsent(parent, parentDepth) == null) {
160+
queue.add(parent);
161+
}
162+
}
163+
}
164+
}
165+
return null;
166+
}
167+
}

0 commit comments

Comments
 (0)