Skip to content

Commit 2e03683

Browse files
committed
Add the Morphy lemmatizer with exception-list support
1 parent 474d73c commit 2e03683

10 files changed

Lines changed: 668 additions & 0 deletions

File tree

opennlp-extensions/opennlp-wordnet/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@
4848
<artifactId>junit-jupiter-engine</artifactId>
4949
<scope>test</scope>
5050
</dependency>
51+
52+
<dependency>
53+
<groupId>org.junit.jupiter</groupId>
54+
<artifactId>junit-jupiter-params</artifactId>
55+
<scope>test</scope>
56+
</dependency>
5157
</dependencies>
5258

5359
</project>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
package opennlp.wordnet;
18+
19+
import java.io.IOException;
20+
import java.io.UncheckedIOException;
21+
import java.nio.charset.StandardCharsets;
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
24+
import java.util.ArrayList;
25+
import java.util.EnumMap;
26+
import java.util.HashMap;
27+
import java.util.List;
28+
import java.util.Locale;
29+
import java.util.Map;
30+
31+
import opennlp.tools.commons.ThreadSafe;
32+
import opennlp.tools.wordnet.WordNetPos;
33+
34+
/**
35+
* The Morphy exception lists: the per-part-of-speech tables of irregular inflected forms
36+
* ({@code mice} to {@code mouse}, {@code went} to {@code go}) that the Morphy algorithm
37+
* consults before its detachment rules.
38+
*
39+
* <p>{@link #load(Path)} reads the four {@code *.exc} files ({@code noun.exc},
40+
* {@code verb.exc}, {@code adj.exc}, {@code adv.exc}) in the documented WNDB format: one entry
41+
* per line, the inflected form followed by one or more base forms, space separated, with
42+
* underscores standing for spaces in multiword entries. All four files must be present; note
43+
* that Princeton's database-only download ({@code WNdb}) does not include them, while the full
44+
* WordNet package does. No exception data is bundled with this module; see
45+
* {@link MorphyLemmatizer} for the tiering rationale.</p>
46+
*
47+
* <p>Lookups fold the queried word the same way the lexicon seam folds lemmas: lowercase with
48+
* the root locale, underscore as space.</p>
49+
*
50+
* <p>Instances are immutable after loading and safe for concurrent lookups.</p>
51+
*/
52+
@ThreadSafe
53+
public final class MorphyExceptions {
54+
55+
private final Map<WordNetPos, Map<String, List<String>>> byPos;
56+
57+
private MorphyExceptions(Map<WordNetPos, Map<String, List<String>>> byPos) {
58+
this.byPos = byPos;
59+
}
60+
61+
/**
62+
* Loads the four exception lists from a directory.
63+
*
64+
* @param directory The directory containing {@code noun.exc}, {@code verb.exc},
65+
* {@code adj.exc}, and {@code adv.exc}. Must not be {@code null} and must
66+
* exist.
67+
* @return The loaded exception lists.
68+
* @throws IllegalArgumentException Thrown if {@code directory} is {@code null} or not a
69+
* directory, one of the four files is missing, or a line is malformed; the message names
70+
* the file and line.
71+
* @throws UncheckedIOException Thrown if reading a file fails.
72+
*/
73+
public static MorphyExceptions load(Path directory) {
74+
if (directory == null) {
75+
throw new IllegalArgumentException("Directory must not be null");
76+
}
77+
if (!Files.isDirectory(directory)) {
78+
throw new IllegalArgumentException(
79+
"Directory does not exist or is not a directory: " + directory);
80+
}
81+
final Map<WordNetPos, Map<String, List<String>>> byPos = new EnumMap<>(WordNetPos.class);
82+
byPos.put(WordNetPos.NOUN, loadFile(directory, "noun.exc"));
83+
byPos.put(WordNetPos.VERB, loadFile(directory, "verb.exc"));
84+
byPos.put(WordNetPos.ADJECTIVE, loadFile(directory, "adj.exc"));
85+
byPos.put(WordNetPos.ADVERB, loadFile(directory, "adv.exc"));
86+
return new MorphyExceptions(byPos);
87+
}
88+
89+
/**
90+
* Finds the base forms of an irregular inflected form.
91+
*
92+
* @param word The inflected form; folded before lookup. Must not be {@code null}.
93+
* @param pos The part of speech. Must not be {@code null}.
94+
* @return The base forms in file order, never {@code null}; empty when the word has no entry.
95+
* @throws IllegalArgumentException Thrown if {@code word} or {@code pos} is {@code null}.
96+
*/
97+
public List<String> lookup(String word, WordNetPos pos) {
98+
if (word == null) {
99+
throw new IllegalArgumentException("Word must not be null");
100+
}
101+
if (pos == null) {
102+
throw new IllegalArgumentException("Pos must not be null");
103+
}
104+
final List<String> lemmas = byPos.get(pos).get(fold(word));
105+
return lemmas == null ? List.of() : lemmas;
106+
}
107+
108+
static String fold(String word) {
109+
return word.replace('_', ' ').toLowerCase(Locale.ROOT);
110+
}
111+
112+
private static Map<String, List<String>> loadFile(Path directory, String fileName) {
113+
final Path file = directory.resolve(fileName);
114+
if (!Files.isRegularFile(file)) {
115+
throw new IllegalArgumentException("Missing exception list file: " + file);
116+
}
117+
final List<String> lines;
118+
try {
119+
lines = Files.readAllLines(file, StandardCharsets.ISO_8859_1);
120+
} catch (IOException e) {
121+
throw new UncheckedIOException("Unable to read exception list file " + file, e);
122+
}
123+
final Map<String, List<String>> entries = new HashMap<>(lines.size() * 2);
124+
for (int i = 0; i < lines.size(); i++) {
125+
final String line = lines.get(i);
126+
if (line.isEmpty()) {
127+
continue;
128+
}
129+
final List<String> fields = splitOnSpaces(line);
130+
if (fields.size() < 2) {
131+
throw new IllegalArgumentException("Malformed exception list " + fileName + " at line "
132+
+ (i + 1) + ": expected an inflected form and at least one base form, got: " + line);
133+
}
134+
final List<String> lemmas = new ArrayList<>(fields.size() - 1);
135+
for (final String lemma : fields.subList(1, fields.size())) {
136+
lemmas.add(fold(lemma));
137+
}
138+
// A form listed twice keeps its first entry, matching first-match lookup semantics.
139+
entries.putIfAbsent(fold(fields.get(0)), List.copyOf(lemmas));
140+
}
141+
return Map.copyOf(entries);
142+
}
143+
144+
private static List<String> splitOnSpaces(String line) {
145+
final List<String> fields = new ArrayList<>(3);
146+
int start = 0;
147+
while (start < line.length()) {
148+
final int space = line.indexOf(' ', start);
149+
if (space < 0) {
150+
fields.add(line.substring(start));
151+
break;
152+
}
153+
if (space > start) {
154+
fields.add(line.substring(start, space));
155+
}
156+
start = space + 1;
157+
}
158+
return fields;
159+
}
160+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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+
package opennlp.wordnet;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import java.util.Locale;
22+
23+
import opennlp.tools.commons.ThreadSafe;
24+
import opennlp.tools.lemmatizer.Lemmatizer;
25+
import opennlp.tools.wordnet.WordNetLexicon;
26+
import opennlp.tools.wordnet.WordNetPos;
27+
28+
/**
29+
* A clean-room implementation of the documented Morphy algorithm as a {@link Lemmatizer}:
30+
* exception-list lookup first, then the per-part-of-speech iterative detachment rules, with
31+
* every rule-derived candidate validated against a {@link WordNetLexicon} before it is
32+
* returned.
33+
*
34+
* <p><b>Algorithm.</b> For each token the part-of-speech tag is mapped to a
35+
* {@link WordNetPos}; the token is folded (lowercase with the root locale, underscore as
36+
* space); the {@link MorphyExceptions exception lists} are consulted and a hit is returned
37+
* directly, without lexicon validation, because the lists themselves are authoritative for
38+
* irregular forms; otherwise the folded token itself, when the lexicon contains it, and every
39+
* detachment-rule result the lexicon confirms become the candidate lemmas, in rule order. The
40+
* rules are the documented Morphy suffix substitutions: for nouns {@code -s}, {@code -ses},
41+
* {@code -xes}, {@code -zes}, {@code -ches}, {@code -shes}, {@code -men}, {@code -ies}; for
42+
* verbs {@code -s}, {@code -ies}, {@code -es}, {@code -ed}, {@code -ing} (with their {@code e}
43+
* restorations); for adjectives {@code -er} and {@code -est} (plain and {@code e}-restoring);
44+
* and none for adverbs, which rely on the exception list alone. Returned lemmas are in the
45+
* lexicon's folded canonical form (lowercase, spaces in multiword lemmas).</p>
46+
*
47+
* <p><b>Tag mapping.</b> Tags map to WordNet parts of speech by their conventional Penn
48+
* Treebank prefixes: {@code N} to noun, {@code V} to verb, {@code J} to adjective, and
49+
* {@code R} to adverb, case-insensitively. The names {@code ADJ} and {@code ADV} and the
50+
* WordNet letters {@code n}, {@code v}, {@code a}, {@code r}, and {@code s} (satellite,
51+
* treated as adjective) are also accepted. Anything else, including Penn tags for closed
52+
* classes such as {@code DT} or {@code MD}, maps to no part of speech and yields the
53+
* unknown-word result.</p>
54+
*
55+
* <p><b>Unknown words.</b> Following the convention of
56+
* {@code opennlp.tools.lemmatizer.DictionaryLemmatizer}, a token with no lemma yields the
57+
* string {@code "O"} from {@link #lemmatize(String[], String[])} and a singleton
58+
* {@code ["O"]} from {@link #lemmatize(List, List)}.</p>
59+
*
60+
* <p>No lexicon or exception data is bundled: point {@link WndbReader} and
61+
* {@link MorphyExceptions#load(java.nio.file.Path) MorphyExceptions} at a WordNet database
62+
* directory you
63+
* downloaded, or combine {@link WnLmfReader} with a downloaded exception-list directory. Both
64+
* inputs are required because rule-derived candidates are meaningless without a lexicon to
65+
* validate them against; an exception-only mode would be a misleading half-feature, so it does
66+
* not exist. Bundling the permissively licensed data is a recorded follow-up.</p>
67+
*
68+
* <p>Instances are immutable and safe for concurrent use once constructed with a loaded
69+
* lexicon and exception lists.</p>
70+
*/
71+
@ThreadSafe
72+
public final class MorphyLemmatizer implements Lemmatizer {
73+
74+
/** The unknown-word output, matching the DictionaryLemmatizer convention. */
75+
private static final String UNKNOWN = "O";
76+
77+
private static final String[][] NOUN_RULES = {
78+
{"s", ""}, {"ses", "s"}, {"xes", "x"}, {"zes", "z"},
79+
{"ches", "ch"}, {"shes", "sh"}, {"men", "man"}, {"ies", "y"},
80+
};
81+
82+
private static final String[][] VERB_RULES = {
83+
{"s", ""}, {"ies", "y"}, {"es", "e"}, {"es", ""},
84+
{"ed", "e"}, {"ed", ""}, {"ing", "e"}, {"ing", ""},
85+
};
86+
87+
private static final String[][] ADJECTIVE_RULES = {
88+
{"er", ""}, {"est", ""}, {"er", "e"}, {"est", "e"},
89+
};
90+
91+
private static final String[][] NO_RULES = {};
92+
93+
private final WordNetLexicon lexicon;
94+
private final MorphyExceptions exceptions;
95+
96+
/**
97+
* Creates a Morphy lemmatizer over a loaded lexicon and exception lists.
98+
*
99+
* @param lexicon The lexicon rule candidates are validated against. Must not be
100+
* {@code null}.
101+
* @param exceptions The irregular-form exception lists. Must not be {@code null}.
102+
* @throws IllegalArgumentException Thrown if {@code lexicon} or {@code exceptions} is
103+
* {@code null}.
104+
*/
105+
public MorphyLemmatizer(WordNetLexicon lexicon, MorphyExceptions exceptions) {
106+
if (lexicon == null) {
107+
throw new IllegalArgumentException("Lexicon must not be null");
108+
}
109+
if (exceptions == null) {
110+
throw new IllegalArgumentException("Exceptions must not be null");
111+
}
112+
this.lexicon = lexicon;
113+
this.exceptions = exceptions;
114+
}
115+
116+
@Override
117+
public String[] lemmatize(String[] toks, String[] tags) {
118+
if (toks == null || tags == null) {
119+
throw new IllegalArgumentException("Toks and tags must not be null");
120+
}
121+
if (toks.length != tags.length) {
122+
throw new IllegalArgumentException("Toks and tags must have the same length, got "
123+
+ toks.length + " and " + tags.length);
124+
}
125+
final String[] lemmas = new String[toks.length];
126+
for (int i = 0; i < toks.length; i++) {
127+
final List<String> candidates = lemmasOf(toks[i], tags[i]);
128+
lemmas[i] = candidates.isEmpty() ? UNKNOWN : candidates.get(0);
129+
}
130+
return lemmas;
131+
}
132+
133+
@Override
134+
public List<List<String>> lemmatize(List<String> toks, List<String> tags) {
135+
if (toks == null || tags == null) {
136+
throw new IllegalArgumentException("Toks and tags must not be null");
137+
}
138+
if (toks.size() != tags.size()) {
139+
throw new IllegalArgumentException("Toks and tags must have the same size, got "
140+
+ toks.size() + " and " + tags.size());
141+
}
142+
final List<List<String>> lemmas = new ArrayList<>(toks.size());
143+
for (int i = 0; i < toks.size(); i++) {
144+
final List<String> candidates = lemmasOf(toks.get(i), tags.get(i));
145+
lemmas.add(candidates.isEmpty() ? List.of(UNKNOWN) : candidates);
146+
}
147+
return lemmas;
148+
}
149+
150+
// All lemmas of one token, most preferred first; empty when the word is unknown.
151+
private List<String> lemmasOf(String token, String tag) {
152+
if (token == null || tag == null) {
153+
throw new IllegalArgumentException("Tokens and tags must not contain null elements");
154+
}
155+
final WordNetPos pos = posFromTag(tag);
156+
if (pos == null) {
157+
return List.of();
158+
}
159+
final String folded = MorphyExceptions.fold(token);
160+
final List<String> irregular = exceptions.lookup(folded, pos);
161+
if (!irregular.isEmpty()) {
162+
return irregular;
163+
}
164+
final List<String> candidates = new ArrayList<>(2);
165+
if (lexicon.contains(folded, pos)) {
166+
candidates.add(folded);
167+
}
168+
for (final String[] rule : rulesFor(pos)) {
169+
final String suffix = rule[0];
170+
if (folded.length() > suffix.length() && folded.endsWith(suffix)) {
171+
final String candidate =
172+
folded.substring(0, folded.length() - suffix.length()) + rule[1];
173+
if (!candidates.contains(candidate) && lexicon.contains(candidate, pos)) {
174+
candidates.add(candidate);
175+
}
176+
}
177+
}
178+
return candidates;
179+
}
180+
181+
private static String[][] rulesFor(WordNetPos pos) {
182+
return switch (pos) {
183+
case NOUN -> NOUN_RULES;
184+
case VERB -> VERB_RULES;
185+
case ADJECTIVE -> ADJECTIVE_RULES;
186+
case ADVERB -> NO_RULES;
187+
};
188+
}
189+
190+
// The documented tag mapping; package-private so tests can pin it directly.
191+
static WordNetPos posFromTag(String tag) {
192+
if (tag.isEmpty()) {
193+
return null;
194+
}
195+
final String upper = tag.toUpperCase(Locale.ROOT);
196+
if (upper.startsWith("ADJ")) {
197+
return WordNetPos.ADJECTIVE;
198+
}
199+
if (upper.startsWith("ADV")) {
200+
return WordNetPos.ADVERB;
201+
}
202+
return switch (upper.charAt(0)) {
203+
case 'N' -> WordNetPos.NOUN;
204+
case 'V' -> WordNetPos.VERB;
205+
case 'J', 'A', 'S' -> WordNetPos.ADJECTIVE;
206+
case 'R' -> WordNetPos.ADVERB;
207+
default -> null;
208+
};
209+
}
210+
}

0 commit comments

Comments
 (0)