|
| 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