Skip to content

Commit 2def6bf

Browse files
committed
OPENNLP-1930: End a quoted lemma at the first matching quote at a field boundary
The leaf scans searched backward from the end of the line, so a lemma extended to the last quote whose rest still parsed, the greedy behavior of the replaced patterns: =H:n("x" M S)<tab>a') b gave lemma 'x" M S)<tab>a', no morphology, and lexeme 'b'. Now the first matching quote followed by whitespace, a secondary tag, or a closing bracket ends the field, giving lemma 'x', morphology 'M S', and lexeme 'a') b'. Embedded word quotes stay literal, and the square bracket after some Floresta time and measurement lemmas is accepted. Red: seven assertion failures over 247 parser cases. Green: 500 focused AD cases. No parsed field changed for the 920,203 quoted tree-line candidates in Bosque and FlorestaVirgem. Adds evalPortugueseTokenizerDefaultNaiveBayes, which cross-validates the registered AD token converter with the default preserved compounds and ADDetokenizer (F1 0.9951936406770416, separate reference boundaries from the split path). The manual documents the lemma boundary rule.
1 parent 9594594 commit 2def6bf

4 files changed

Lines changed: 93 additions & 30 deletions

File tree

opennlp-core/opennlp-formats/src/main/java/opennlp/tools/formats/ad/ADSentenceStream.java

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public static class SentenceParser {
7474
private static final char GROUP_CLOSE = ')';
7575
private static final char BRACKET_OPEN = '<';
7676
private static final char BRACKET_CLOSE = '>';
77+
private static final char SQUARE_BRACKET_CLOSE = ']';
7778
private static final char DOUBLE_QUOTE = '"';
7879
private static final char SINGLE_QUOTE = '\'';
7980
private static final char CLOSING_GUILLEMET = '\u00BB';
@@ -478,26 +479,47 @@ private boolean isLemmaStart(String line, int from) {
478479
*/
479480
private Leaf parseLeafAfterTag(String line, Tag tag, LineScan scan) {
480481
int lemmaStart = tag.end() + 2;
481-
// the longest lemma after which the rest of the line still parses is used
482-
for (int lemmaEnd = line.length() - 1; lemmaEnd > lemmaStart; lemmaEnd--) {
483-
if (!isQuote(line.charAt(lemmaEnd))) {
484-
continue;
485-
}
486-
int tagsStart = skipWhitespace(line, lemmaEnd + 1);
487-
LeafRest rest = scanSecondaryTags(line, tagsStart, scan);
488-
if (rest != null) {
489-
Leaf leaf = new Leaf();
490-
leaf.setLevel(tag.start() + 1);
491-
leaf.setSyntacticTag(line.substring(tag.start(), tag.separator()));
492-
leaf.setFunctionalTag(line.substring(tag.separator() + 1, tag.end()));
493-
leaf.setLemma(line.substring(lemmaStart, lemmaEnd));
494-
leaf.setSecondaryTag(line.substring(tagsStart, rest.tagsEnd()));
495-
leaf.setMorphologicalTag(morphology(line, rest.end()));
496-
leaf.setLexeme(line.substring(rest.end().lexemeStart()));
497-
return leaf;
482+
int lemmaEnd = lemmaEnd(line, lemmaStart - 1);
483+
if (lemmaEnd <= lemmaStart) {
484+
return null;
485+
}
486+
int tagsStart = skipWhitespace(line, lemmaEnd + 1);
487+
LeafRest rest = scanSecondaryTags(line, tagsStart, scan);
488+
if (rest == null) {
489+
return null;
490+
}
491+
Leaf leaf = new Leaf();
492+
leaf.setLevel(tag.start() + 1);
493+
leaf.setSyntacticTag(line.substring(tag.start(), tag.separator()));
494+
leaf.setFunctionalTag(line.substring(tag.separator() + 1, tag.end()));
495+
leaf.setLemma(line.substring(lemmaStart, lemmaEnd));
496+
leaf.setSecondaryTag(line.substring(tagsStart, rest.tagsEnd()));
497+
leaf.setMorphologicalTag(morphology(line, rest.end()));
498+
leaf.setLexeme(line.substring(rest.end().lexemeStart()));
499+
return leaf;
500+
}
501+
502+
/**
503+
* Finds the matching quote that ends a lemma field. A quote followed by whitespace, a
504+
* secondary tag, or a closing parenthesis or square bracket ends the field; quotes
505+
* embedded in a word remain literal. Later quotes in the morphology or lexeme cannot
506+
* extend the lemma.
507+
*
508+
* @param line The tree line.
509+
* @param open The index of the opening quote.
510+
* @return The closing quote index, or -1 if the field has no closing quote.
511+
*/
512+
private int lemmaEnd(String line, int open) {
513+
char quote = line.charAt(open);
514+
for (int i = open + 1; i < line.length(); i++) {
515+
if (line.charAt(i) == quote && (i + 1 == line.length()
516+
|| StringUtil.isUnicodeWhitespace(line.charAt(i + 1))
517+
|| line.charAt(i + 1) == BRACKET_OPEN || line.charAt(i + 1) == GROUP_CLOSE
518+
|| line.charAt(i + 1) == SQUARE_BRACKET_CLOSE)) {
519+
return i;
498520
}
499521
}
500-
return null;
522+
return -1;
501523
}
502524

503525
/**
@@ -540,12 +562,11 @@ private Leaf parseBizarreLeafAfterTag(String line, Tag tag, LineScan scan) {
540562
String lemma = null;
541563
LeafEnd end = null;
542564
if (open < line.length() && isQuote(line.charAt(open))) {
543-
for (int lemmaEnd = line.length() - 1; lemmaEnd > open + 1 && end == null; lemmaEnd--) {
544-
if (isQuote(line.charAt(lemmaEnd))) {
545-
end = scanLeafEnd(line, lemmaEnd + 1, scan);
546-
if (end != null) {
547-
lemma = line.substring(open + 1, lemmaEnd);
548-
}
565+
int lemmaEnd = lemmaEnd(line, open);
566+
if (lemmaEnd > open + 1) {
567+
end = scanLeafEnd(line, lemmaEnd + 1, scan);
568+
if (end != null) {
569+
lemma = line.substring(open + 1, lemmaEnd);
549570
}
550571
}
551572
}

opennlp-core/opennlp-formats/src/test/java/opennlp/tools/formats/ad/ADSentenceStreamTest.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,21 @@ private static Stream<Arguments> leafLines() {
202202
// quotes inside lemma and lexeme
203203
Arguments.of("=H:n(\"d'água\" <x> M S)\td'água",
204204
2, "H", "n", "d'água", "<x>", "M S", "d'água"),
205-
// the lemma extends to the last quote after which the rest of the line still parses
206-
Arguments.of("=H:n(\"a\" <b> \"c\")\tw", 2, "H", "n", "a\" <b> \"c", "", null, "w"),
207-
Arguments.of("=H:n(\"x\" M S)\ta') b", 2, "H", "n", "x\" M S)\ta", "", null, "b"),
205+
// A closing lemma quote ends the field, even if later fields contain quotes.
206+
Arguments.of("=H:n(\"a\" <b> \"c\")\tw", 2, "H", "n", "a", "<b>", "\"c\"", "w"),
207+
Arguments.of("=H:n(\"x\" M S)\ta') b", 2, "H", "n", "x", "", "M S", "a') b"),
208+
Arguments.of("=H:n(\"x\" M S)\ta\") b", 2, "H", "n", "x", "", "M S", "a\") b"),
209+
Arguments.of("=H:n('x' M S)\ta') b", 2, "H", "n", "x", "", "M S", "a') b"),
210+
// Embedded quotes without a field boundary remain part of the lemma.
211+
Arguments.of("=H:n('d'água' M S)\td'água",
212+
2, "H", "n", "d'água", "", "M S", "d'água"),
213+
Arguments.of("=H:prop(\"Eduardo_2º_\"=,=de=Marlwe\" M S)\tEduardo",
214+
2, "H", "prop", "Eduardo_2º_\"=,=de=Marlwe", "", "M S", "Eduardo"),
215+
Arguments.of("=H:n(\"x\"\u00A0M S)\ta\") b",
216+
2, "H", "n", "x", "", "M S", "a\") b"),
217+
// Floresta has an extra square bracket after some time/measurement lemmas.
218+
Arguments.of("=H:n(\"8h\"] <temp> F P)\t8h\"",
219+
2, "H", "n", "8h", "", "] <temp> F P", "8h\""),
208220
// the secondary tags extend to the last closing angle bracket
209221
Arguments.of("=H:n(\"casa\" <a>b<c> M S)\tcasa",
210222
2, "H", "n", "casa", "<a>b<c>", "M S", "casa"),
@@ -246,6 +258,8 @@ private static Stream<Arguments> bizarreLeafLines() {
246258
Arguments.of("=x=y() b", 2, "x=y", null, null, "b"),
247259
// a quoted part without a closing quote is the morphological tag
248260
Arguments.of("=x=y(\"q) b", 2, "x=y", null, "\"q", "b"),
261+
Arguments.of("=x=y(\"q\" M S)\ta') b", 2, "x=y", "q", "M S", "a') b"),
262+
Arguments.of("=x=y('q' M S)\ta') b", 2, "x=y", "q", "M S", "a') b"),
249263
// the level prefix gives up hyphens so that the tag can start
250264
Arguments.of("==-=x(a) b", 3, "-=x", null, "a", "b"),
251265
Arguments.of("=-=x=y(a) b", 4, "x=y", null, "a", "b"),

opennlp-docs/src/docbkx/corpora.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,12 @@ Execution time: 1,955 seconds]]>
467467
detokenizer dictionary's hyphen joining rule to reconstruct <code>ofereceu-me</code>
468468
without adding a space or changing those annotated token boundaries.
469469
</para>
470+
<para>
471+
A quoted lemma ends at a matching quote followed by a field boundary. Embedded
472+
apostrophes and quotes within a word remain part of the lemma. Quotes in subsequent
473+
annotation fields or in the surface text do not extend the lemma or remove part of
474+
the token text.
475+
</para>
470476
<para>
471477
The AD reader separates the parts of a line on whitespace as the Unicode White_Space
472478
property defines it, independent of the toolkit whitespace mode: tabs, no-break spaces,

opennlp-eval-tests/src/test/java/opennlp/tools/eval/ArvoresDeitadasEval.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@
2828

2929
import opennlp.tools.chunker.ChunkerCrossValidator;
3030
import opennlp.tools.chunker.ChunkerFactory;
31+
import opennlp.tools.cmdline.StreamFactoryRegistry;
3132
import opennlp.tools.formats.ad.ADChunkSampleStream;
3233
import opennlp.tools.formats.ad.ADNameSampleStream;
34+
import opennlp.tools.formats.ad.ADNameSampleStreamFactory;
3335
import opennlp.tools.formats.ad.ADSentenceSampleStream;
36+
import opennlp.tools.formats.ad.ADTokenSampleStreamFactory;
3437
import opennlp.tools.formats.convert.NameToTokenSampleStream;
3538
import opennlp.tools.namefind.NameSample;
3639
import opennlp.tools.sentdetect.SDCrossValidator;
@@ -114,14 +117,20 @@ private void tokenizerCrossEval(TrainingParameters params,
114117
ObjectStream<TokenSample> samples = new NameToTokenSampleStream(
115118
detokenizer, nameSamples);
116119

117-
TokenizerCrossValidator validator;
120+
tokenizerCrossEval(params, expectedScore, samples);
121+
}
118122

123+
/** Evaluates the supplied conversion path with identical training and held-out settings. */
124+
private void tokenizerCrossEval(TrainingParameters params, double expectedScore,
125+
ObjectStream<TokenSample> samples) throws IOException {
119126
TokenizerFactory tokFactory = TokenizerFactory.create(null, LANG, null,
120127
true, null);
121-
validator = new opennlp.tools.tokenize.TokenizerCrossValidator(params,
128+
TokenizerCrossValidator validator = new TokenizerCrossValidator(params,
122129
tokFactory);
123130

124-
validator.evaluate(samples, 10);
131+
try (samples) {
132+
validator.evaluate(samples, 10);
133+
}
125134

126135
Assertions.assertEquals(expectedScore, validator.getFMeasure().getFMeasure(), 0.0001d);
127136
}
@@ -178,6 +187,19 @@ void evalPortugueseTokenizerNaiveBayes() throws IOException {
178187
tokenizerCrossEval(createNaiveBayesParams(), 0.9962358244502717d);
179188
}
180189

190+
/** Exercises the CLI default, including preserved compounds and AD clitic detokenization. */
191+
@Test
192+
void evalPortugueseTokenizerDefaultNaiveBayes() throws IOException {
193+
ADNameSampleStreamFactory.registerFactory();
194+
ADTokenSampleStreamFactory.registerFactory();
195+
ObjectStream<TokenSample> samples = StreamFactoryRegistry.getFactory(TokenSample.class, "ad")
196+
.create(new String[] {"-data", new File(getOpennlpDataDir(), FLORESTA_VIRGEM).getPath(),
197+
"-lang", LANG, "-encoding", StandardCharsets.ISO_8859_1.name(),
198+
"-detokenizer", "lang/pt/tokenizer/pt-detokenizer.xml"});
199+
// Preserved compounds have different reference boundaries from the split-token baseline.
200+
tokenizerCrossEval(createNaiveBayesParams(), 0.9951936406770416d, samples);
201+
}
202+
181203
@Test
182204
void evalPortugueseTokenizerMaxentQnMultipleThreads() throws IOException {
183205
TrainingParameters params = createMaxentQnParams();

0 commit comments

Comments
 (0)