This is an automated email from the ASF dual-hosted git repository.
mawiesne pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git
The following commit(s) were added to refs/heads/main by this push:
new e2f6fbc61 OPENNLP-1865: Support offset-safe input normalization in the
DL components (#1105)
e2f6fbc61 is described below
commit e2f6fbc61b03bc7e0ee103b9510e109c62d039bd
Author: Kristian Rickert <[email protected]>
AuthorDate: Wed Jul 8 00:38:29 2026 -0400
OPENNLP-1865: Support offset-safe input normalization in the DL components
(#1105)
* OPENNLP-1865 Offset-safe, Unicode-aware input normalization in the DL
components
* For broader context, see epic:
https://issues.apache.org/jira/browse/OPENNLP-1850
Follow-up:
* OPENNLP-205: Update the stale SourceForgeModelEval golden hash for
evalSentenceModel
The Jenkins eval-tests-configurable job failed evalSentenceModel after
OPENNLP-205 (d5d37dca, #1141) landed: it pins a SHA digest of every
sentence SentenceDetectorME produces over the Leipzig
eng_news_2010_300K corpus using the SourceForge en-sent.bin model, and
OPENNLP-205 deliberately changed which characters SentenceDetectorME
trims as whitespace (Unicode White_Space, not StringUtil.isWhitespace).
The corpus contains at least one boundary affected by that documented
delta, so the digest changed. No other test in the class touches
SentenceDetectorME, and every other digest in the 138-test run was
unaffected, which is consistent with an isolated, expected fallout of
the OPENNLP-205 fix rather than a regression.
---
.../tools/namefind/OffsetMappingNameFinder.java | 46 +++
opennlp-core/opennlp-ml/opennlp-dl/README.md | 54 ++++
opennlp-core/opennlp-ml/opennlp-dl/pom.xml | 15 +-
.../src/main/java/opennlp/dl/AbstractDL.java | 174 +++++++++++-
.../src/main/java/opennlp/dl/InferenceOptions.java | 42 +++
.../opennlp/dl/doccat/DocumentCategorizerDL.java | 75 +++--
.../java/opennlp/dl/namefinder/NameFinderDL.java | 308 ++++++++++++++++-----
.../java/opennlp/dl/AbstractDLChunkingTest.java | 161 +++++++++++
.../dl/doccat/DocumentCategorizerDLTest.java | 72 +++++
.../opennlp/dl/namefinder/NameFinderDLTest.java | 120 +++++++-
.../dl/doccat/DocumentCategorizerDLEval.java | 2 +-
.../opennlp/dl/namefinder/NameFinderDLEval.java | 152 ++++++++++
.../opennlp/tools/eval/SourceForgeModelEval.java | 8 +-
13 files changed, 1117 insertions(+), 112 deletions(-)
diff --git
a/opennlp-api/src/main/java/opennlp/tools/namefind/OffsetMappingNameFinder.java
b/opennlp-api/src/main/java/opennlp/tools/namefind/OffsetMappingNameFinder.java
new file mode 100644
index 000000000..dd86e3580
--- /dev/null
+++
b/opennlp-api/src/main/java/opennlp/tools/namefind/OffsetMappingNameFinder.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.tools.namefind;
+
+import opennlp.tools.util.Span;
+
+/**
+ * A {@link TokenNameFinder} that can additionally report detected spans in
the character coordinates
+ * of the original input, mapping back through any text normalization applied
before detection.
+ *
+ * <p>An implementation that normalizes input before detection (for example an
ONNX model that folds
+ * Unicode whitespace or dashes) returns spans from {@link #find(String[])} in
the coordinates of the
+ * normalized text, which no longer line up with the caller's input when a
fold changes the length.
+ * {@link #findInOriginal(String[])} maps those spans back to original-input
coordinates. This is a
+ * separate capability interface rather than a method on {@link
TokenNameFinder} because the classic
+ * contract reports token-index spans, for which an original-character mapping
is not meaningful; an
+ * interface-typed caller tests for the capability ({@code finder instanceof
OffsetMappingNameFinder})
+ * instead of depending on a concrete implementation.</p>
+ */
+public interface OffsetMappingNameFinder extends TokenNameFinder {
+
+ /**
+ * Finds names and returns their {@link Span spans} in the character
coordinates of the original
+ * input, regardless of any normalization applied before detection.
+ *
+ * @param tokens The tokens to search.
+ * @return The detected spans, in original-input character coordinates.
+ * @throws IllegalArgumentException Thrown if {@code tokens} is {@code null}
or contains a
+ * {@code null} token.
+ */
+ Span[] findInOriginal(String[] tokens);
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-dl/README.md
b/opennlp-core/opennlp-ml/opennlp-dl/README.md
index 04a7715d4..fc6a79245 100644
--- a/opennlp-core/opennlp-ml/opennlp-dl/README.md
+++ b/opennlp-core/opennlp-ml/opennlp-dl/README.md
@@ -22,6 +22,37 @@ Named entity models are commonly cased, so lower casing is
disabled by default.
Set `InferenceOptions#setLowerCase(true)` only for models trained with uncased
input.
+### Unicode text handling
+
+Long input is split into overlapping chunks on the full Unicode `White_Space`
+set (not Java's `\s`), so no-break space, ideographic space, and the other UCD
+whitespace characters are recognized as delimiters. `NameFinderDL` locates
+reconstructed entity text in the original input with a cursor-based matcher
that
+treats span spaces as flexible Unicode whitespace and compares other code
points
+case-insensitively, so `Span#getCoveredText(...)` works on text from PDFs, the
+web, and multilingual sources.
+
+Optional input folding is off by default and controlled through
+`InferenceOptions`:
+
+```java
+InferenceOptions options = new InferenceOptions();
+options.setNormalizeWhitespace(true); // each Unicode whitespace -> ASCII
space (offset-preserving)
+options.setNormalizeDashes(true); // Unicode dashes -> hyphen-minus
(offset note below)
+NameFinderDL finder = new NameFinderDL(model, vocab, ids2Labels, options,
sentenceDetector);
+```
+
+Whitespace folding is length-preserving, so it never moves offsets. Dash
folding can shrink a
+non-BMP dash by one UTF-16 unit, but `NameFinderDL.findInOriginal` maps
decoded spans back through
+the normalization `Alignment`, so reported spans stay correct in the original
input even for
+non-BMP dashes. (`NameFinderDL.find` returns normalized-text offsets, which
differ from the
+original only in that non-BMP-dash case.)
+
+The same options apply to `DocumentCategorizerDL`. The underlying
+`CharClass` / `CodePointSet` engine and the broader normalization pipeline live
+in `opennlp.tools.util.normalizer` and are documented in the OpenNLP manual
+chapter *Text Normalization*.
+
Export a Hugging Face NER model to ONNX, e.g.:
```bash
@@ -30,12 +61,35 @@ python -m transformers.onnx --model=dslim/bert-base-NER
--feature token-classifi
## DocumentCategorizerDL
+Uses the same Unicode whitespace chunking and optional `InferenceOptions`
+normalization as `NameFinderDL` (see above).
+
Export a Huggingface classification (e.g. sentiment) model to ONNX, e.g.:
```bash
python -m transformers.onnx
--model=nlptown/bert-base-multilingual-uncased-sentiment --feature
sequence-classification exported
```
+## Behavior changes in this release
+
+Integrators upgrading from an earlier `opennlp-dl` should note these
intentional changes (OPENNLP-1850):
+
+- `NameFinderDL.find(...)` reports spans in the coordinates of the joined
input it ran inference on,
+ which differ from the original text only when length-changing dash folding
is enabled. Use the new
+ `NameFinderDL.findInOriginal(...)` (from `OffsetMappingNameFinder`) for
original-text coordinates.
+- Spans that overlap at chunk boundaries are now merged longest-wins;
`find(...)` previously returned
+ every decoded span, overlaps included.
+- Chunking splits on the Unicode `White_Space` set rather than
`String#split("\\s+")`, and
+ whitespace-only input now yields no spans without running the model.
+- `DocumentCategorizerDL.categorize(...)` now rejects `null`/empty input, and
a document with no
+ non-whitespace token, with `IllegalArgumentException` rather than running
the model on empty input.
+- A `null` input array, a `null` token passed to `NameFinderDL`, or a `null`
document passed to
+ `DocumentCategorizerDL` is rejected with `IllegalArgumentException` instead
of surfacing as a
+ `NullPointerException` deep in processing or being joined into the text as
the literal string
+ `"null"`.
+- The example label constants `NameFinderDL.I_PER` and `NameFinderDL.B_PER`
were removed; supply your
+ own label strings (any `B-<TYPE>`/`I-<TYPE>` pair works, as described above).
+
## SentenceVectors
Convert a sentence vectors model to ONNX, e.g.:
diff --git a/opennlp-core/opennlp-ml/opennlp-dl/pom.xml
b/opennlp-core/opennlp-ml/opennlp-dl/pom.xml
index 76d27f6fa..55d887ca3 100644
--- a/opennlp-core/opennlp-ml/opennlp-dl/pom.xml
+++ b/opennlp-core/opennlp-ml/opennlp-dl/pom.xml
@@ -37,6 +37,14 @@
<groupId>org.apache.opennlp</groupId>
<artifactId>opennlp-api</artifactId>
</dependency>
+ <!-- Test-only: some DL tests construct concrete runtime components (e.g.
a sentence detector).
+ The Unicode normalization engine (CharClass) now lives in
opennlp-api, so the main code
+ compiles against opennlp-api plus onnxruntime only. -->
+ <dependency>
+ <groupId>org.apache.opennlp</groupId>
+ <artifactId>opennlp-runtime</artifactId>
+ <scope>test</scope>
+ </dependency>
<!-- External dependencies -->
<dependency>
@@ -45,13 +53,6 @@
<version>${onnxruntime.version}</version>
</dependency>
- <!-- TEST scope -->
- <dependency>
- <groupId>org.apache.opennlp</groupId>
- <artifactId>opennlp-runtime</artifactId>
- <scope>test</scope>
- </dependency>
-
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
diff --git
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
index fe7cc99d5..5b61fc6e8 100644
---
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
+++
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
@@ -26,7 +26,6 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
@@ -40,6 +39,10 @@ import ai.onnxruntime.OrtSession;
import opennlp.tools.tokenize.BertTokenizer;
import opennlp.tools.tokenize.Tokenizer;
import opennlp.tools.tokenize.WordpieceTokenizer;
+import opennlp.tools.util.Span;
+import opennlp.tools.util.normalizer.AlignedText;
+import opennlp.tools.util.normalizer.Alignment;
+import opennlp.tools.util.normalizer.CharClass;
/**
* Base class for OpenNLP deep-learning classes using ONNX Runtime.
@@ -60,6 +63,17 @@ public abstract class AbstractDL implements AutoCloseable {
protected record ChunkRange(int start, int end) {
}
+ /**
+ * A rejoined chunk paired with its half-open character span in the text it
was split from, so a
+ * chunk's decoded entities can be located within the region the chunk
actually covers.
+ *
+ * @param text The chunk text, the chunk's whitespace tokens rejoined with
single ASCII spaces.
+ * @param start The inclusive character offset of the chunk in the source
text.
+ * @param end The exclusive character offset of the chunk in the source
text.
+ */
+ protected record TextChunk(String text, int start, int end) {
+ }
+
private static final Pattern JSON_ENTRY_PATTERN =
Pattern.compile("\"((?:[^\"\\\\]|\\\\.)*)\"\\s*:\\s*(\\d+)");
@@ -82,9 +96,9 @@ public abstract class AbstractDL implements AutoCloseable {
protected AbstractDL(final File model, final File vocabulary,
final OrtSession.SessionOptions sessionOptions, final
boolean lowerCase)
throws IOException, OrtException {
- Objects.requireNonNull(model, "model");
- Objects.requireNonNull(vocabulary, "vocabulary");
- Objects.requireNonNull(sessionOptions, "sessionOptions");
+ requireNonNullArg(model, "model");
+ requireNonNullArg(vocabulary, "vocabulary");
+ requireNonNullArg(sessionOptions, "sessionOptions");
this.env = OrtEnvironment.getEnvironment();
// try-with-resources closes the session options once the session has
consumed them.
try (sessionOptions) {
@@ -136,7 +150,7 @@ public abstract class AbstractDL implements AutoCloseable {
*/
protected static OrtSession.SessionOptions sessionOptions(final
InferenceOptions inferenceOptions)
throws OrtException {
- Objects.requireNonNull(inferenceOptions, "inferenceOptions");
+ requireNonNullArg(inferenceOptions, "inferenceOptions");
validateSplitOptions(inferenceOptions);
final OrtSession.SessionOptions sessionOptions = new
OrtSession.SessionOptions();
if (inferenceOptions.isGpu()) {
@@ -292,7 +306,7 @@ public abstract class AbstractDL implements AutoCloseable {
*/
protected static boolean resolveLowerCase(
final InferenceOptions options, final boolean componentDefault) {
- Objects.requireNonNull(options, "options");
+ requireNonNullArg(options, "options");
return options.getLowerCase() != null ? options.getLowerCase() :
componentDefault;
}
@@ -303,7 +317,7 @@ public abstract class AbstractDL implements AutoCloseable {
* @throws IllegalArgumentException Thrown if the split settings cannot make
progress.
*/
protected static void validateSplitOptions(final InferenceOptions options) {
- Objects.requireNonNull(options, "options");
+ requireNonNullArg(options, "options");
validateSplitOptions(options.getDocumentSplitSize(),
options.getSplitOverlapSize());
}
@@ -316,15 +330,146 @@ public abstract class AbstractDL implements
AutoCloseable {
*/
protected static void validateSplitOptions(final int documentSplitSize,
final int splitOverlapSize) {
if (documentSplitSize <= 0) {
- throw new IllegalArgumentException("documentSplitSize must be greater
than zero.");
+ throw new IllegalArgumentException("The documentSplitSize must be
greater than zero.");
}
if (splitOverlapSize < 0) {
- throw new IllegalArgumentException("splitOverlapSize must not be
negative.");
+ throw new IllegalArgumentException("The splitOverlapSize must not be
negative.");
}
if (splitOverlapSize >= documentSplitSize) {
throw new IllegalArgumentException(
- "splitOverlapSize must be smaller than documentSplitSize.");
+ "The splitOverlapSize must be smaller than documentSplitSize.");
+ }
+ }
+
+ /**
+ * Unicode-aware whitespace. Input is tokenized on the full Unicode {@code
White_Space} set
+ * rather than the six ASCII characters Java's {@code \s} recognizes, and
the same class is
+ * reused by subclasses that need to match against whitespace in the source
text.
+ */
+ protected static final CharClass WHITESPACE = CharClass.whitespace();
+
+ /** Unicode dashes (excluding the mathematical minus signs), used for
optional input folding. */
+ protected static final CharClass DASHES = CharClass.dashes();
+
+ /**
+ * Optionally folds Unicode whitespace and/or dashes in the input to their
ASCII forms before
+ * inference, returning just the folded text. This is suitable for callers
that do not map model
+ * output back to character offsets, such as whole-document classification.
When the result must
+ * be mapped back to the original text (for example to report entity spans),
use
+ * {@link #normalizeInputAligned(String, boolean, boolean)} instead, which
also returns an
+ * {@link Alignment} that stays correct even when a fold changes the string
length.
+ *
+ * @param text The input text.
+ * @param normalizeWhitespace Whether to fold whitespace to ASCII spaces.
+ * @param normalizeDashes Whether to fold dashes to the ASCII hyphen.
+ * @return The optionally normalized text.
+ */
+ protected static String normalizeInput(final String text, final boolean
normalizeWhitespace,
+ final boolean normalizeDashes) {
+ String result = text;
+ if (normalizeWhitespace) {
+ result = WHITESPACE.normalize(result).toString();
+ }
+ if (normalizeDashes) {
+ result = DASHES.normalize(result).toString();
}
+ return result;
+ }
+
+ /**
+ * Like {@link #normalizeInput(String, boolean, boolean)} but also produces
an {@link Alignment}
+ * from the folded text back to {@code text}, so model output positions map
to original character
+ * offsets even when a fold changes the string length (a supplementary dash
shrinking, or, for
+ * folds that may be added later, an expansion such as an ellipsis to three
dots).
+ *
+ * @param text The input text.
+ * @param normalizeWhitespace Whether to fold whitespace to ASCII spaces.
+ * @param normalizeDashes Whether to fold dashes to the ASCII hyphen.
+ * @return The optionally normalized text paired with its alignment back to
{@code text}.
+ */
+ protected static AlignedText normalizeInputAligned(final String text,
+ final boolean normalizeWhitespace, final boolean normalizeDashes) {
+ // Compose each enabled fold's alignment with the running alignment so the
returned mapping is
+ // correct no matter whether a stage changes length. Whitespace folding
here is a one-for-one
+ // replacement and so is length-preserving today; only dash folding moves
offsets (a
+ // supplementary-plane dash shrinks from two chars to one). Composing
through andThen rather
+ // than relying on the whitespace stage staying length-preserving keeps
findInOriginal() correct
+ // if that ever changes.
+ AlignedText result = identityAligned(text, text);
+ if (normalizeWhitespace) {
+ result = compose(result,
WHITESPACE.normalizeAligned(result.normalized()));
+ }
+ if (normalizeDashes) {
+ result = compose(result, DASHES.normalizeAligned(result.normalized()));
+ }
+ return result;
+ }
+
+ // Threads a fold stage onto the running alignment: accumulated maps
original -> current and next
+ // maps current -> next.normalized(), so the composition maps original ->
next.normalized().
+ private static AlignedText compose(final AlignedText accumulated, final
AlignedText next) {
+ return new AlignedText(accumulated.original(), next.normalized(),
+ accumulated.alignment().andThen(next.alignment()));
+ }
+
+ // An AlignedText whose alignment is the identity, for the case where no
length-changing fold was
+ // applied so the folded text has the same length and offsets as the
original.
+ private static AlignedText identityAligned(final String original, final
String normalized) {
+ final Alignment alignment =
+ new
Alignment.Builder().equal(normalized.length()).build(normalized.length());
+ return new AlignedText(original, normalized, alignment);
+ }
+
+ /**
+ * Splits {@code text} on Unicode whitespace and groups the resulting tokens
into overlapping
+ * chunks, each rejoined with single ASCII spaces, ready for WordPiece
tokenization. The split
+ * uses the Unicode {@code White_Space} set, so spacing such as a no-break
space or the
+ * ideographic space is recognized, and it yields no empty tokens from
leading, trailing, or
+ * repeated whitespace.
+ *
+ * @param text The input text.
+ * @param documentSplitSize The maximum number of whitespace tokens per
chunk.
+ * @param splitOverlapSize The number of tokens shared between consecutive
chunks.
+ * @return The chunk strings, in order.
+ */
+ protected static List<String> whitespaceChunks(final String text, final int
documentSplitSize,
+ final int splitOverlapSize) {
+ final List<TextChunk> chunks = whitespaceChunkSpans(text,
documentSplitSize, splitOverlapSize);
+ final List<String> groups = new ArrayList<>(chunks.size());
+ for (final TextChunk chunk : chunks) {
+ groups.add(chunk.text());
+ }
+ return groups;
+ }
+
+ /**
+ * Like {@link #whitespaceChunks(String, int, int)} but also carries each
chunk's character span
+ * in {@code text}, so a chunk can be decoded bounded to the region it
covers and overlapping
+ * chunks yield overlapping candidate spans rather than silently dropping a
boundary entity.
+ *
+ * @param text The input text.
+ * @param documentSplitSize The maximum number of whitespace tokens per
chunk.
+ * @param splitOverlapSize The number of tokens shared between consecutive
chunks.
+ * @return The chunks, in order, each with its character span in {@code
text}.
+ */
+ protected static List<TextChunk> whitespaceChunkSpans(final String text,
+ final int documentSplitSize, final int splitOverlapSize) {
+ final List<Span> tokenSpans = WHITESPACE.splitSpans(text);
+ final List<TextChunk> chunks = new ArrayList<>();
+ for (final ChunkRange range : chunkRanges(tokenSpans.size(),
documentSplitSize,
+ splitOverlapSize)) {
+ final StringBuilder rejoined = new StringBuilder();
+ for (int i = range.start(); i < range.end(); i++) {
+ if (i > range.start()) {
+ rejoined.append(' ');
+ }
+ rejoined.append(text, tokenSpans.get(i).getStart(),
tokenSpans.get(i).getEnd());
+ }
+ final int start = tokenSpans.get(range.start()).getStart();
+ final int end = tokenSpans.get(range.end() - 1).getEnd();
+ chunks.add(new TextChunk(rejoined.toString(), start, end));
+ }
+ return chunks;
}
/**
@@ -340,7 +485,7 @@ public abstract class AbstractDL implements AutoCloseable {
protected static List<ChunkRange> chunkRanges(final int tokenCount, final
int documentSplitSize,
final int splitOverlapSize) {
if (tokenCount < 0) {
- throw new IllegalArgumentException("tokenCount must not be negative.");
+ throw new IllegalArgumentException("The tokenCount must not be
negative.");
}
validateSplitOptions(documentSplitSize, splitOverlapSize);
@@ -429,4 +574,11 @@ public abstract class AbstractDL implements AutoCloseable {
}
}
+ // Null parameters report IllegalArgumentException rather than
requireNonNull's
+ // NullPointerException, matching the parameter contract of the engine and
tokenizer layers.
+ protected static void requireNonNullArg(Object value, String name) {
+ if (value == null) {
+ throw new IllegalArgumentException("The " + name + " must not be null.");
+ }
+ }
}
diff --git
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/InferenceOptions.java
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/InferenceOptions.java
index 344c5846d..b59aac226 100644
---
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/InferenceOptions.java
+++
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/InferenceOptions.java
@@ -26,6 +26,8 @@ public class InferenceOptions {
private int documentSplitSize = 250;
private int splitOverlapSize = 50;
private Boolean lowerCase;
+ private boolean normalizeWhitespace;
+ private boolean normalizeDashes;
public boolean isIncludeAttentionMask() {
return includeAttentionMask;
@@ -75,6 +77,46 @@ public class InferenceOptions {
this.splitOverlapSize = splitOverlapSize;
}
+ /** {@return whether input whitespace is normalized to ASCII spaces before
inference} */
+ public boolean isNormalizeWhitespace() {
+ return normalizeWhitespace;
+ }
+
+ /**
+ * Replaces every Unicode whitespace character in the input with an ASCII
space before inference.
+ * This is offset preserving (each whitespace code point maps to one space),
so any spans a model
+ * produces still align with the input. Off by default.
+ *
+ * <p>This is a one-for-one replacement, not the collapse-and-trim
whitespace fold of the runtime
+ * {@code TextNormalizer.whitespace()} rung: runs of whitespace are not
merged and leading or
+ * trailing whitespace is not removed, so offsets are preserved.</p>
+ *
+ * @param normalizeWhitespace Whether to normalize whitespace.
+ */
+ public void setNormalizeWhitespace(boolean normalizeWhitespace) {
+ this.normalizeWhitespace = normalizeWhitespace;
+ }
+
+ /** {@return whether input dashes are normalized to the ASCII hyphen before
inference} */
+ public boolean isNormalizeDashes() {
+ return normalizeDashes;
+ }
+
+ /**
+ * Replaces Unicode dashes in the input with the ASCII hyphen-minus before
inference. This is
+ * offset preserving for the dash characters in the Basic Multilingual Plane
(the common case).
+ * The mathematical minus signs are not affected. Off by default.
+ *
+ * <p>A supplementary-plane dash shrinks from two chars to one, which shifts
later offsets, so
+ * with this enabled {@code find(...)} reports offsets into the normalized
text in that case. Use
+ * {@code NameFinderDL.findInOriginal(...)} for offsets mapped back to the
original input.</p>
+ *
+ * @param normalizeDashes Whether to normalize dashes.
+ */
+ public void setNormalizeDashes(boolean normalizeDashes) {
+ this.normalizeDashes = normalizeDashes;
+ }
+
/**
* Returns whether tokenization should lower case the input text and strip
* accents, as required by uncased models.
diff --git
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
index 7aa36e494..a9c6dd9c5 100644
---
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
+++
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
@@ -28,7 +28,6 @@ import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
-import java.util.Objects;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
@@ -85,6 +84,8 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
private final boolean includeTokenTypeIds;
private final int documentSplitSize;
private final int splitOverlapSize;
+ private final boolean normalizeWhitespace;
+ private final boolean normalizeDashes;
/**
* Test-only constructor that injects an already-built {@link OrtSession}
(or {@code null}),
@@ -101,6 +102,8 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
this.includeTokenTypeIds = inferenceOptions.isIncludeTokenTypeIds();
this.documentSplitSize = inferenceOptions.getDocumentSplitSize();
this.splitOverlapSize = inferenceOptions.getSplitOverlapSize();
+ this.normalizeWhitespace = inferenceOptions.isNormalizeWhitespace();
+ this.normalizeDashes = inferenceOptions.isNormalizeDashes();
}
/**
@@ -132,6 +135,8 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
this.includeTokenTypeIds = inferenceOptions.isIncludeTokenTypeIds();
this.documentSplitSize = inferenceOptions.getDocumentSplitSize();
this.splitOverlapSize = inferenceOptions.getSplitOverlapSize();
+ this.normalizeWhitespace = inferenceOptions.isNormalizeWhitespace();
+ this.normalizeDashes = inferenceOptions.isNormalizeDashes();
}
@@ -165,17 +170,21 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
this.includeTokenTypeIds = inferenceOptions.isIncludeTokenTypeIds();
this.documentSplitSize = inferenceOptions.getDocumentSplitSize();
this.splitOverlapSize = inferenceOptions.getSplitOverlapSize();
+ this.normalizeWhitespace = inferenceOptions.isNormalizeWhitespace();
+ this.normalizeDashes = inferenceOptions.isNormalizeDashes();
}
private static InferenceOptions validateConstructorArguments(
final InferenceOptions inferenceOptions, final Object categoriesOrConfig,
final ClassificationScoringStrategy classificationScoringStrategy) {
- Objects.requireNonNull(categoriesOrConfig, "categoriesOrConfig");
- Objects.requireNonNull(classificationScoringStrategy,
"classificationScoringStrategy");
+ requireNonNullArg(inferenceOptions, "inferenceOptions");
+ requireNonNullArg(categoriesOrConfig, "categoriesOrConfig");
+ requireNonNullArg(classificationScoringStrategy,
"classificationScoringStrategy");
return inferenceOptions;
}
+
/**
* Categorizes the document, failing loudly rather than returning an invalid
distribution:
* malformed input is rejected with {@link IllegalArgumentException}, and
any failure executing
@@ -183,24 +192,45 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
*
* @param strings The document to categorize; {@code strings[0]} is
classified.
* @return The per-category probabilities.
- * @throws IllegalArgumentException If {@code strings} is {@code null} or
empty.
+ * @throws IllegalArgumentException If {@code strings} is {@code null} or
empty, or if
+ * {@code strings[0]} has no tokens to classify (it is empty or only
whitespace).
* @throws IllegalStateException If inference fails or the model returns
an unexpected output.
*/
@Override
public double[] categorize(String[] strings) {
if (strings == null || strings.length == 0) {
- throw new IllegalArgumentException("strings must contain at least one
document to categorize");
+ throw new IllegalArgumentException(
+ "The strings argument must contain at least one document to
categorize");
+ }
+
+ if (strings[0] == null) {
+ throw new IllegalArgumentException("The document to categorize must not
be null");
}
final List<Tokens> tokens = tokenize(strings[0]);
+ if (tokens.isEmpty()) {
+ throw new IllegalArgumentException(
+ "The document to categorize must contain at least one non-whitespace
token");
+ }
final List<double[]> scores = new LinkedList<>();
for (final Tokens t : tokens) {
scores.add(softmax(infer(t)));
}
- return classificationScoringStrategy.score(scores);
+ final double[] distribution = classificationScoringStrategy.score(scores);
+ return requireMatchingCategoryCount(distribution, categories.size());
+ }
+
+ // Package-visible so the model/category-count mismatch guard can be
exercised without a live model.
+ static double[] requireMatchingCategoryCount(final double[] distribution,
final int expected) {
+ if (distribution.length != expected) {
+ throw new IllegalStateException("The model produced " +
distribution.length
+ + " category scores but the categorizer is configured with " +
expected
+ + " categories; the model and the category configuration do not
match");
+ }
+ return distribution;
}
/**
@@ -236,6 +266,12 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
inputs.values().forEach(OnnxTensor::close);
}
+ return logitsFromOutput(output);
+ }
+
+ // Package-visible so the output-shape dispatch, including the null and
unexpected-type failures,
+ // can be exercised without a live model session.
+ static float[] logitsFromOutput(final Object output) {
// Some models return a 2D array (e.g. BERT), others a 1D array (e.g.
RoBERTa). A different
// shape is a model-contract violation, surfaced on its own rather than as
"inference failed".
if (output instanceof float[][] v) {
@@ -243,7 +279,8 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
} else if (output instanceof float[] v) {
return v;
}
- throw new IllegalStateException("Unexpected model output type: " +
output.getClass().getName());
+ throw new IllegalStateException("Unexpected model output type: "
+ + (output == null ? "null" : output.getClass().getName()));
}
@Override
@@ -327,21 +364,15 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
}
- private List<Tokens> tokenize(final String text) {
+ private List<Tokens> tokenize(final String input) {
+ final String text = normalizeInput(input, normalizeWhitespace,
normalizeDashes);
final List<Tokens> t = new LinkedList<>();
- // Segment long input text into overlapping chunks configured by
InferenceOptions before
- // feeding each chunk into BERT.
+ // Segment long input text into overlapping chunks (split on Unicode
whitespace) configured by
+ // InferenceOptions before feeding each chunk into BERT.
//
https://medium.com/analytics-vidhya/text-classification-with-bert-using-transformers-for-long-text-inputs-f54833994dfd
- final String[] whitespaceTokenized = text.split("\\s+");
-
- for (ChunkRange chunkRange : chunkRanges(
- whitespaceTokenized.length, documentSplitSize, splitOverlapSize)) {
-
- // The group is that subsection of string.
- final String group = String.join(" ",
- Arrays.copyOfRange(whitespaceTokenized, chunkRange.start(),
chunkRange.end()));
+ for (final String group : whitespaceChunks(text, documentSplitSize,
splitOverlapSize)) {
// Now we can tokenize the group and continue.
final String[] tokens = tokenizer.tokenize(group);
@@ -402,6 +433,14 @@ public class DocumentCategorizerDL extends AbstractDL
implements DocumentCategor
// identical to the naive form. Results are kept in double precision
throughout.
double max = Double.NEGATIVE_INFINITY;
for (final float value : input) {
+ // Reject any non-finite logit, not just NaN: a +Infinity logit makes
max == +Inf, so
+ // value - max is Inf - Inf == NaN and the whole distribution silently
goes NaN. Subtracting
+ // the maximum already handles merely-large finite logits, so only
NaN/Infinity reach here.
+ if (!Float.isFinite(value)) {
+ throw new IllegalStateException(
+ "The model produced a non-finite logit (NaN or Infinity); cannot
compute a "
+ + "classification distribution");
+ }
max = Math.max(max, value);
}
diff --git
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/namefinder/NameFinderDL.java
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/namefinder/NameFinderDL.java
index e5b5c89b5..2da72e58d 100644
---
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/namefinder/NameFinderDL.java
+++
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/namefinder/NameFinderDL.java
@@ -22,14 +22,13 @@ import java.io.IOException;
import java.nio.LongBuffer;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
-import java.util.Objects;
import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import java.util.TreeMap;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtException;
@@ -41,12 +40,15 @@ import opennlp.dl.AbstractDL;
import opennlp.dl.InferenceOptions;
import opennlp.dl.Tokens;
import opennlp.tools.commons.ThreadSafe;
-import opennlp.tools.namefind.TokenNameFinder;
+import opennlp.tools.namefind.OffsetMappingNameFinder;
import opennlp.tools.sentdetect.SentenceDetector;
+import opennlp.tools.tokenize.WordpieceTokenizer;
import opennlp.tools.util.Span;
+import opennlp.tools.util.normalizer.AlignedText;
+import opennlp.tools.util.normalizer.Alignment;
/**
- * An implementation of {@link TokenNameFinder} that uses ONNX models.
+ * An implementation of {@link opennlp.tools.namefind.TokenNameFinder} that
uses ONNX models.
*
* <p>Tokenization performs BERT basic tokenization (text normalization)
* before wordpiece, see {@link opennlp.tools.tokenize.BertTokenizer}. Input
@@ -64,18 +66,20 @@ import opennlp.tools.util.Span;
* guarantee applies until {@link #close()} is called; callers must not race
* {@code close()} with inference methods.</p>
*
- * @see TokenNameFinder
+ * @see opennlp.tools.namefind.TokenNameFinder
* @see InferenceOptions
*/
@ThreadSafe
-public class NameFinderDL extends AbstractDL implements TokenNameFinder {
+public class NameFinderDL extends AbstractDL implements
OffsetMappingNameFinder {
- /** Example person labels; retained for reference. Decoding handles any
B-/I- type. */
- public static final String I_PER = "I-PER";
- public static final String B_PER = "B-PER";
public static final String SEPARATOR = "[SEP]";
public static final String CLS_TOKEN = "[CLS]";
+ // Tokenizer-added markers (BERT and RoBERTa) that must never appear in a
reconstructed span.
+ private static final Set<String> SPECIAL_TOKENS = Set.of(
+ CLS_TOKEN, SEPARATOR,
+ WordpieceTokenizer.ROBERTA_CLS_TOKEN,
WordpieceTokenizer.ROBERTA_SEP_TOKEN);
+
/** Prefix used by BIO labels for the first token in an entity span. */
public static final String PREFIX_BEGIN = "B-";
@@ -94,6 +98,13 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
private static final boolean LOWER_CASE_DEFAULT = false;
private static final String CHARS_TO_REPLACE = "##";
+
+ // Ordering for overlap resolution: longest span first, then higher
probability. The dominant
+ // detection is kept and any later span overlapping it is dropped.
+ private static final Comparator<Span> BY_LENGTH_THEN_PROBABILITY =
+ Comparator.comparingInt(Span::length).reversed()
+ .thenComparing(Comparator.comparingDouble(Span::getProb).reversed());
+
private static final Logger logger =
LoggerFactory.getLogger(NameFinderDL.class);
private final SentenceDetector sentenceDetector;
@@ -104,9 +115,11 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
private final boolean includeTokenTypeIds;
private final int documentSplitSize;
private final int splitOverlapSize;
+ private final boolean normalizeWhitespace;
+ private final boolean normalizeDashes;
/**
- * Instantiates a {@link TokenNameFinder name finder} using ONNX models.
+ * Instantiates a {@link opennlp.tools.namefind.TokenNameFinder name finder}
using ONNX models.
*
* @param model The ONNX model file.
* @param vocabulary The model file's vocabulary file.
@@ -117,6 +130,8 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
*
* @throws OrtException Thrown if the {@code model} cannot be loaded.
* @throws IOException Thrown if errors occurred loading the {@code model}
or {@code vocabulary}.
+ * @throws IllegalArgumentException Thrown if {@code inferenceOptions},
{@code ids2Labels}, or
+ * the sentence detector is {@code null}.
*/
public NameFinderDL(File model, File vocabulary, Map<Integer, String>
ids2Labels,
SentenceDetector sentenceDetector) throws IOException,
OrtException {
@@ -126,7 +141,7 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
}
/**
- * Instantiates a {@link TokenNameFinder name finder} using ONNX models.
+ * Instantiates a {@link opennlp.tools.namefind.TokenNameFinder name finder}
using ONNX models.
*
* @param model The ONNX model file.
* @param vocabulary The model file's vocabulary file.
@@ -138,6 +153,8 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
*
* @throws OrtException Thrown if the {@code model} cannot be loaded.
* @throws IOException Thrown if errors occurred loading the {@code model}
or {@code vocabulary}.
+ * @throws IllegalArgumentException Thrown if {@code inferenceOptions},
{@code ids2Labels}, or
+ * the sentence detector is {@code null}.
*/
public NameFinderDL(File model, File vocabulary, Map<Integer, String>
ids2Labels,
InferenceOptions inferenceOptions,
@@ -153,6 +170,8 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
this.includeTokenTypeIds = inferenceOptions.isIncludeTokenTypeIds();
this.documentSplitSize = inferenceOptions.getDocumentSplitSize();
this.splitOverlapSize = inferenceOptions.getSplitOverlapSize();
+ this.normalizeWhitespace = inferenceOptions.isNormalizeWhitespace();
+ this.normalizeDashes = inferenceOptions.isNormalizeDashes();
this.sentenceDetector = sentenceDetector;
}
@@ -160,62 +179,166 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
private static InferenceOptions validateConstructorArguments(
final InferenceOptions inferenceOptions, final Map<Integer, String>
ids2Labels,
final SentenceDetector sentenceDetector) {
- Objects.requireNonNull(ids2Labels, "ids2Labels");
- Objects.requireNonNull(sentenceDetector, "sentenceDetector");
+ requireNonNullArg(inferenceOptions, "inferenceOptions");
+ requireNonNullArg(ids2Labels, "ids2Labels");
+ requireNonNullArg(sentenceDetector, "sentenceDetector");
return inferenceOptions;
}
+
/**
* {@inheritDoc}
*
- * <p>This method joins the provided tokens with spaces, sentence-splits the
joined text,
- * runs each sentence through the ONNX token-classification model, decodes
BIO labels into
- * {@link Span spans}, and resolves those spans back to character offsets in
the joined text.</p>
+ * <p>Joins the provided tokens with spaces, sentence-splits the joined
text, runs each sentence
+ * through the ONNX token-classification model, decodes BIO labels into
{@link Span spans}, and
+ * resolves those spans to character offsets in the joined text
<em>after</em> any optional input
+ * normalization.</p>
+ *
+ * <p>Note: this returns correct original offsets in every case except one.
Whitespace folding is
+ * length-preserving, so it never moves offsets. Only dash folding can
change the input length, and
+ * only for a non-BMP dash; so when {@code normalizeDashes} is enabled and
the input contains a
+ * supplementary-plane dash, the returned spans are offsets into the
normalized text rather than
+ * the original. For an exact original mapping in that case, use {@link
#findInOriginal(String[])}.</p>
*
* @throws IllegalStateException Thrown if inference fails, if the model
output shape is not
* the expected {@code float[batch][token][label]} form, if the model
output contains
* no usable label score for a token, or if the model's predicted index
for a token is not
* present in the configured label map.
- * @throws IllegalArgumentException Thrown if a token produced for the input
is not present in
- * the vocabulary, which indicates the vocabulary file does not match
the model.
+ * @throws IllegalArgumentException Thrown if {@code input} is {@code null}
or contains a
+ * {@code null} token, or if a token produced for the input is not
present in the
+ * vocabulary, which indicates the vocabulary file does not match the
model.
*/
@Override
public Span[] find(String[] input) {
+ return locate(input).spans().toArray(new Span[0]);
+ }
- final List<Span> spans = new ArrayList<>();
+ /**
+ * Finds names and returns their {@link Span spans} in coordinates of the
original joined input
+ * ({@code String.join(" ", input)}), regardless of any whitespace or dash
normalization applied
+ * before inference. Spans are mapped back through the normalization {@link
Alignment}, so a fold
+ * that changes the input length (a supplementary dash shrinking, or an
expansion) does not shift
+ * the reported offsets. This implements {@link OffsetMappingNameFinder}, so
an interface-typed
+ * caller can reach the offset-correct path with
+ * {@code finder instanceof OffsetMappingNameFinder}.
+ *
+ * @param input The tokens to search.
+ * @return The detected spans, in original-input character coordinates.
+ * @throws IllegalStateException Thrown under the same conditions as {@link
#find(String[])}.
+ * @throws IllegalArgumentException Thrown under the same conditions as
{@link #find(String[])}.
+ */
+ @Override
+ public Span[] findInOriginal(String[] input) {
+ final DecodedSpans decoded = locate(input);
+ final Alignment alignment = decoded.aligned().alignment();
+ final List<Span> mapped = new ArrayList<>(decoded.spans().size());
+ for (final Span span : decoded.spans()) {
+ final Span original = alignment.toOriginalSpan(span.getStart(),
span.getEnd());
+ mapped.add(new Span(original.getStart(), original.getEnd(),
span.getType(), span.getProb()));
+ }
+ return mapped.toArray(new Span[0]);
+ }
+
+ /**
+ * Shared detection core: normalizes the joined input (capturing the
alignment back to the
+ * original), then decodes each overlapping chunk bounded to its own
character region and resolves
+ * overlaps. Bounding per chunk lets a boundary entity that two consecutive
chunks both cover
+ * surface as overlapping candidates, which {@link
#mergeOverlappingSpans(List)} collapses to the
+ * longer (more complete) span instead of silently keeping whichever a
single forward cursor
+ * reached first.
+ *
+ * @param input The tokens to search; must not be {@code null} or contain a
{@code null} token.
+ * @return The decoded spans paired with the normalization {@link
AlignedText}.
+ * @throws IllegalArgumentException Thrown if {@code input} is {@code null}
or contains a
+ * {@code null} token.
+ */
+ private DecodedSpans locate(String[] input) {
+
+ requireNonNullArg(input, "input");
+ for (int i = 0; i < input.length; i++) {
+ if (input[i] == null) {
+ throw new IllegalArgumentException(
+ "The input must not contain null tokens; the token at index " + i
+ " was null.");
+ }
+ }
// Join the tokens here because they will be tokenized using Wordpiece
during inference.
- final String text = String.join(" ", input);
+ final AlignedText normalized =
+ normalizeInputAligned(String.join(" ", input), normalizeWhitespace,
normalizeDashes);
+ final String text = normalized.normalizedString();
// sentPosDetect (not sentDetect) so each sentence's offset in the full
text is known.
final Span[] sentenceSpans = sentenceDetector.sentPosDetect(text);
+ final List<Span> candidates = new ArrayList<>();
for (final Span sentenceSpan : sentenceSpans) {
- // Floor the character cursor at this sentence's start, then thread it
forward across the
- // sentence's chunks so a repeated surface form is located at its next
occurrence. Flooring
- // per sentence keeps an entity from being matched against an identical
surface form in an
- // earlier sentence -- even one that produced no spans, which would
otherwise leave the
- // cursor behind and mis-locate the match.
- int searchStart = sentenceSpan.getStart();
-
- // The WordPiece tokenized text. This changes the spacing in the text.
- final List<Tokens> wordpieceTokens =
tokenize(sentenceSpan.getCoveredText(text).toString());
-
- for (final Tokens tokens : wordpieceTokens) {
- final List<Span> decoded =
- decodeSpans(text, tokens.tokens(), infer(tokens), ids2Labels,
searchStart,
- sentenceSpan.getEnd());
- spans.addAll(decoded);
- if (!decoded.isEmpty()) {
- searchStart = decoded.get(decoded.size() - 1).getEnd();
- }
+ final int sentenceStart = sentenceSpan.getStart();
+ final String sentence = sentenceSpan.getCoveredText(text).toString();
+
+ // The WordPiece tokenized text, in overlapping chunks. This changes the
spacing in the text.
+ for (final ChunkTokens chunk : tokenize(sentence)) {
+ // Decode within the chunk's own character region in the full text.
Keeping each chunk's
+ // entities inside the region it was built from locates a repeated
surface form in the right
+ // chunk rather than mis-matching it to an earlier occurrence, while
still letting two
+ // overlapping chunks both emit a boundary entity for
mergeOverlappingSpans to reconcile.
+ final int regionStart = sentenceStart + chunk.start();
+ final int regionEnd = sentenceStart + chunk.end();
+ candidates.addAll(decodeSpans(text, chunk.tokens().tokens(),
infer(chunk.tokens()),
+ ids2Labels, regionStart, regionEnd));
}
}
- return spans.toArray(new Span[0]);
+ return new DecodedSpans(mergeOverlappingSpans(candidates), normalized);
+ }
+
+ private record DecodedSpans(List<Span> spans, AlignedText aligned) {
+ }
+
+ // A chunk's WordPiece tokens paired with the chunk's half-open character
span in the full text.
+ private record ChunkTokens(Tokens tokens, int start, int end) {
+ }
+ /**
+ * Resolves spans that overlap in character coordinates, as happens when an
entity falls in the
+ * shared region of two consecutive overlapping chunks and is decoded by
both. The longer span is
+ * kept (the more complete decode) and ties break toward the higher
probability; any span that
+ * overlaps an already kept one is dropped. Adjacent but disjoint spans are
never merged, so
+ * neighbouring distinct entities and repeated surface forms at different
offsets are preserved.
+ * The returned list is in document order. The choice is length-dominant
rather than type-aware:
+ * when two overlapping spans carry different entity types, the longer still
wins regardless of
+ * type, which is the intended heuristic for the rare cross-type overlap at
a chunk boundary.
+ *
+ * @param spans The decoded candidate spans, in the order they were produced.
+ * @return The overlap-free spans, ordered by start offset.
+ */
+ static List<Span> mergeOverlappingSpans(final List<Span> spans) {
+ if (spans.size() < 2) {
+ // Return a fresh list so the caller always owns the result, matching
the >= 2 path below
+ // (which returns a new list); the input is never handed back aliased.
+ return new ArrayList<>(spans);
+ }
+ final List<Span> byDominance = new ArrayList<>(spans);
+ byDominance.sort(BY_LENGTH_THEN_PROBABILITY);
+ // Kept spans never overlap each other, so they form a start-sorted
partition. A candidate can
+ // only intersect the kept span that starts at or just before it (floor)
or the next kept span
+ // that starts within it (ceiling); checking those two is O(log n) instead
of scanning every kept
+ // span, making the whole longest-wins pass O(n log n) rather than O(n^2).
Keyed by start, the map
+ // also yields the result already in document order.
+ final TreeMap<Integer, Span> kept = new TreeMap<>();
+ for (final Span candidate : byDominance) {
+ final Map.Entry<Integer, Span> before =
kept.floorEntry(candidate.getStart());
+ if (before != null && before.getValue().getEnd() > candidate.getStart())
{
+ continue;
+ }
+ final Map.Entry<Integer, Span> after =
kept.ceilingEntry(candidate.getStart());
+ if (after != null && after.getKey() < candidate.getEnd()) {
+ continue;
+ }
+ kept.put(candidate.getStart(), candidate);
+ }
+ return new ArrayList<>(kept.values());
}
/**
@@ -356,7 +479,7 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
continue;
}
- final SpanMatch match = findByRegex(text, spanText, characterStart,
searchEnd);
+ final SpanMatch match = findInSource(text, spanText, characterStart,
searchEnd);
if (match.start() != -1) {
spans.add(new Span(match.start(), match.end(), entityType,
entity.probability()));
characterStart = match.end();
@@ -487,9 +610,9 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
/**
* Reconstructs source-like text from a span of WordPiece tokens.
*
- * <p>Special BERT tokens are skipped, {@code ##} continuations are merged
into the preceding
- * surface form, and simple punctuation spacing is normalized so the result
can be located in
- * the caller's original text.</p>
+ * <p>Special BERT and RoBERTa tokens are skipped, {@code ##} continuations
are merged into the
+ * preceding surface form, and simple punctuation spacing is normalized so
the result can be
+ * located in the caller's original text.</p>
*
* @param tokens The WordPiece token sequence.
* @param startIndex The first token index to include.
@@ -503,7 +626,7 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
for (int x = startIndex; x <= endIndex && x < tokens.length; x++) {
final String token = tokens[x];
- if (CLS_TOKEN.equals(token) || SEPARATOR.equals(token)) {
+ if (SPECIAL_TOKENS.contains(token)) {
continue;
}
@@ -567,35 +690,82 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
/**
* Locates reconstructed span text in a bounded region of the original input
text.
*
+ * <p>Matching is a single forward cursor scan, not a regular expression.
Each space in the
+ * reconstructed span matches a run of zero or more Unicode whitespace
characters in the source
+ * (so an entity whose WordPiece pieces were rejoined with spaces, such as
{@code "AT & T"} for
+ * {@code "AT&T"}, is still located), and every other code point matches
case-insensitively.
+ * Using a cursor avoids {@link java.util.regex.Pattern}/{@link
java.util.regex.Matcher}
+ * allocation and the ReDoS surface of regular expressions, and recognizes
Unicode whitespace
+ * that Java's {@code \s} does not.</p>
+ *
* @param text The original text.
- * @param span The reconstructed span text.
+ * @param span The reconstructed span text, with sub-tokens separated by
single ASCII spaces.
* @param searchStart The first character offset to search from.
* @param searchEnd The exclusive upper bound of the region to search.
* @return The matched character offsets, or {@code (-1, -1)} when the
reconstructed text
* cannot be found in the requested region.
*/
- private static SpanMatch findByRegex(String text, String span, int
searchStart, int searchEnd) {
-
- // Reconstructed span text normalizes whitespace, so match flexibly: a
space in the span may
- // map to any run of whitespace OR none in the source (e.g.
punctuation/'&' inside "U.S.A",
- // "AT&T" that wordpiece tokenization split apart). Use \s* rather than
\s+ so such entities
- // are still located instead of being silently dropped.
- final String regex = Pattern.quote(span).replace(" ", "\\E\\s*\\Q");
+ private static SpanMatch findInSource(String text, String span, int
searchStart, int searchEnd) {
- final Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
- final Matcher matcher = pattern.matcher(text);
final int regionStart = Math.min(Math.max(searchStart, 0), text.length());
final int regionEnd = Math.min(Math.max(searchEnd, regionStart),
text.length());
- matcher.region(regionStart, regionEnd);
- if (matcher.find()) {
- return new SpanMatch(matcher.start(), matcher.end());
+ int start = regionStart;
+ while (start < regionEnd) {
+ final int end = matchAt(text, span, start, regionEnd);
+ if (end != -1) {
+ return new SpanMatch(start, end);
+ }
+ start += Character.charCount(text.codePointAt(start));
}
return new SpanMatch(-1, -1);
}
+ /**
+ * Attempts to match {@code span} against {@code text} beginning at {@code
start} and bounded by
+ * {@code regionEnd}. A space in {@code span} consumes a run of zero or more
Unicode whitespace
+ * code points in the source; every other code point must match
case-insensitively.
+ *
+ * @return The exclusive end offset of the match in {@code text}, or {@code
-1} if no match
+ * begins at {@code start}.
+ */
+ private static int matchAt(String text, String span, int start, int
regionEnd) {
+
+ int t = start;
+ int s = 0;
+
+ while (s < span.length()) {
+ final int spanCp = span.codePointAt(s);
+ if (spanCp == ' ') {
+ while (t < regionEnd && WHITESPACE.contains(text.codePointAt(t))) {
+ t += Character.charCount(text.codePointAt(t));
+ }
+ s += 1;
+ } else {
+ if (t >= regionEnd) {
+ return -1;
+ }
+ final int textCp = text.codePointAt(t);
+ if (!equalsIgnoreCase(spanCp, textCp)) {
+ return -1;
+ }
+ t += Character.charCount(textCp);
+ s += Character.charCount(spanCp);
+ }
+ }
+
+ return t;
+
+ }
+
+ private static boolean equalsIgnoreCase(int a, int b) {
+ return a == b
+ || Character.toLowerCase(a) == Character.toLowerCase(b)
+ || Character.toUpperCase(a) == Character.toUpperCase(b);
+ }
+
private record LabelPrediction(String label, double probability) {
}
@@ -609,24 +779,18 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
private record SpanMatch(int start, int end) {
}
- private List<Tokens> tokenize(final String text) {
+ private List<ChunkTokens> tokenize(final String text) {
- final List<Tokens> t = new LinkedList<>();
+ final List<ChunkTokens> t = new LinkedList<>();
- // Segment long input text into overlapping chunks configured by
InferenceOptions before
- // feeding each chunk into BERT.
+ // Segment long input text into overlapping chunks (split on Unicode
whitespace) configured by
+ // InferenceOptions before feeding each chunk into BERT, keeping each
chunk's character span so
+ // its decoded spans can be bounded to the region the chunk covers.
//
https://medium.com/analytics-vidhya/text-classification-with-bert-using-transformers-for-long-text-inputs-f54833994dfd
- final String[] whitespaceTokenized = text.split("\\s+");
-
- for (ChunkRange chunkRange : chunkRanges(
- whitespaceTokenized.length, documentSplitSize, splitOverlapSize)) {
-
- // The group is that subsection of string.
- final String group = String.join(" ",
- Arrays.copyOfRange(whitespaceTokenized, chunkRange.start(),
chunkRange.end()));
+ for (final TextChunk chunk : whitespaceChunkSpans(text, documentSplitSize,
splitOverlapSize)) {
// Now we can tokenize the group and continue.
- final String[] tokens = tokenizer.tokenize(group);
+ final String[] tokens = tokenizer.tokenize(chunk.text());
final long[] ids = tokenIds(tokens, vocab);
@@ -636,7 +800,7 @@ public class NameFinderDL extends AbstractDL implements
TokenNameFinder {
final long[] types = new long[ids.length];
Arrays.fill(types, 0);
- t.add(new Tokens(tokens, ids, mask, types));
+ t.add(new ChunkTokens(new Tokens(tokens, ids, mask, types),
chunk.start(), chunk.end()));
}
diff --git
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/AbstractDLChunkingTest.java
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/AbstractDLChunkingTest.java
new file mode 100644
index 000000000..13957ea9b
--- /dev/null
+++
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/AbstractDLChunkingTest.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package opennlp.dl;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.util.Span;
+import opennlp.tools.util.normalizer.AlignedText;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Model-free tests for {@link AbstractDL#whitespaceChunks(String, int, int)}
and
+ * {@link AbstractDL#whitespaceChunkSpans(String, int, int)}, the shared
tokenize-and-chunk seam
+ * used by both {@code NameFinderDL} and {@code DocumentCategorizerDL}.
+ */
+public class AbstractDLChunkingTest {
+
+ @Test
+ void testSplitsOnUnicodeWhitespaceNotJustAscii() {
+ // A no-break space (U+00A0) and an ideographic space (U+3000) are not
matched by Java's \s
+ // but must still separate tokens; the chunk is rejoined with single ASCII
spaces.
+ final String nbsp = new String(Character.toChars(0x00A0));
+ final String ideographic = new String(Character.toChars(0x3000));
+ assertEquals(List.of("alpha beta gamma"),
+ AbstractDL.whitespaceChunks("alpha" + nbsp + "beta" + ideographic +
"gamma", 100, 0));
+ }
+
+ @Test
+ void testDropsEmptyTokensFromLeadingTrailingAndRepeatedWhitespace() {
+ // Unlike split("\\s+"), the Unicode-aware split yields no empty leading
or trailing tokens.
+ assertEquals(List.of("a b c"), AbstractDL.whitespaceChunks(" a b\tc ",
100, 0));
+ }
+
+ @Test
+ void testAppliesChunkSizeWithoutOverlap() {
+ assertEquals(List.of("a b", "c d"), AbstractDL.whitespaceChunks("a b c d",
2, 0));
+ }
+
+ @Test
+ void testAppliesChunkOverlap() {
+ assertEquals(List.of("a b", "b c", "c d"), AbstractDL.whitespaceChunks("a
b c d", 2, 1));
+ }
+
+ @Test
+ void testEmptyTextYieldsNoChunks() {
+ assertEquals(List.of(), AbstractDL.whitespaceChunks("", 100, 0));
+ }
+
+ @Test
+ void testNormalizeInputIsOptInAndOffsetPreserving() {
+ final String nbsp = new String(Character.toChars(0x00A0));
+ final String emDash = new String(Character.toChars(0x2014));
+ final String input = "a" + nbsp + "b" + emDash + "c";
+
+ // Off by default: unchanged.
+ assertEquals(input, AbstractDL.normalizeInput(input, false, false));
+
+ // Whitespace only: the no-break space becomes a space, and the length is
preserved.
+ final String ws = AbstractDL.normalizeInput(input, true, false);
+ assertEquals("a b" + emDash + "c", ws);
+ assertEquals(input.length(), ws.length());
+
+ // Dashes only: the em dash becomes an ASCII hyphen.
+ assertEquals("a" + nbsp + "b-c", AbstractDL.normalizeInput(input, false,
true));
+
+ // Both.
+ assertEquals("a b-c", AbstractDL.normalizeInput(input, true, true));
+ }
+
+ @Test
+ void testNormalizeInputAlignedMapsSpanBackAcrossSupplementaryDash() {
+ // The aligned path used by NameFinderDL.findInOriginal: a span found in
the folded text maps
+ // back to a tight original span even though the supplementary dash shrank
by one unit.
+ final String yezidi = new String(Character.toChars(0x10EAD));
+ final String input = "a" + yezidi + "b"; // a(0), dash(1,2), b(3) ->
length 4
+ final AlignedText at = AbstractDL.normalizeInputAligned(input, false,
true);
+ assertEquals("a-b", at.normalized());
+
+ final Span b = at.toOriginalSpan(2, 3); // "b" in the folded text
+ assertEquals(3, b.getStart()); // maps back to original offset 3, not
shifted to 2
+ assertEquals(4, b.getEnd());
+
+ final Span hyphen = at.toOriginalSpan(1, 2); // the folded hyphen covers
the two-unit dash
+ assertEquals(1, hyphen.getStart());
+ assertEquals(3, hyphen.getEnd());
+ }
+
+ @Test
+ void testNormalizeInputAlignedIsIdentityWhenLengthPreserved() {
+ // Whitespace folding is length-preserving, so the alignment maps every
position to itself.
+ final AlignedText at = AbstractDL.normalizeInputAligned("a\tb", true,
false);
+ assertEquals("a b", at.normalized());
+ for (int i = 0; i < 3; i++) {
+ final Span s = at.toOriginalSpan(i, i + 1);
+ assertEquals(i, s.getStart());
+ assertEquals(i + 1, s.getEnd());
+ }
+ }
+
+ @Test
+ void testNormalizeInputAlignedComposesWhitespaceAndDash() {
+ // With both folds enabled the alignment is composed across stages, so a
span found in the fully
+ // folded text still maps back to the original even though the
supplementary dash shrank a unit.
+ final String nbsp = new String(Character.toChars(0x00A0));
+ final String yezidi = new String(Character.toChars(0x10EAD));
+ final String input = "a" + nbsp + "b" + yezidi + "c"; // a(0) nbsp(1) b(2)
dash(3,4) c(5)
+ final AlignedText at = AbstractDL.normalizeInputAligned(input, true, true);
+ assertEquals("a b-c", at.normalized()); // a(0) space(1) b(2) hyphen(3)
c(4)
+
+ final Span c = at.toOriginalSpan(4, 5); // "c" maps past the two-unit dash
+ assertEquals(5, c.getStart());
+ assertEquals(6, c.getEnd());
+
+ final Span hyphen = at.toOriginalSpan(3, 4); // folded hyphen covers the
supplementary dash
+ assertEquals(3, hyphen.getStart());
+ assertEquals(5, hyphen.getEnd());
+
+ final Span space = at.toOriginalSpan(1, 2); // the no-break space, length
preserved
+ assertEquals(1, space.getStart());
+ assertEquals(2, space.getEnd());
+ }
+
+ @Test
+ void testWhitespaceChunkSpansCarryCharacterOffsets() {
+ final String text = "a b c d"; // tokens a[0,1) b[2,3) c[4,5) d[6,7)
+ final List<AbstractDL.TextChunk> chunks =
AbstractDL.whitespaceChunkSpans(text, 2, 1);
+
+ assertEquals(3, chunks.size());
+ assertEquals("a b", chunks.get(0).text());
+ assertEquals(0, chunks.get(0).start());
+ assertEquals(3, chunks.get(0).end());
+ assertEquals("b c", chunks.get(1).text());
+ assertEquals(2, chunks.get(1).start());
+ assertEquals(5, chunks.get(1).end());
+ assertEquals("c d", chunks.get(2).text());
+ assertEquals(4, chunks.get(2).start());
+ assertEquals(7, chunks.get(2).end());
+
+ // For single-ASCII-space input the reported span covers the chunk's
rejoined text exactly.
+ for (final AbstractDL.TextChunk chunk : chunks) {
+ assertEquals(chunk.text(), text.substring(chunk.start(), chunk.end()));
+ }
+ }
+}
diff --git
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/doccat/DocumentCategorizerDLTest.java
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/doccat/DocumentCategorizerDLTest.java
index 80087d97c..197d8a3bc 100644
---
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/doccat/DocumentCategorizerDLTest.java
+++
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/doccat/DocumentCategorizerDLTest.java
@@ -85,6 +85,27 @@ public class DocumentCategorizerDLTest {
assertThrows(IllegalArgumentException.class, () ->
categorizer.categorize(new String[0]));
}
+ @Test
+ void testCategorizeRejectsTokenlessContent() {
+ // A non-empty array whose document has no tokens (empty or only
whitespace, including Unicode
+ // whitespace) is rejected up front rather than crashing downstream on an
empty score list.
+ final DocumentCategorizerDL categorizer = categorizerWithoutSession();
+ final String nbsp = new String(Character.toChars(0x00A0));
+
+ assertThrows(IllegalArgumentException.class, () ->
categorizer.categorize(new String[] {""}));
+ assertThrows(IllegalArgumentException.class, () ->
categorizer.categorize(new String[] {" "}));
+ assertThrows(IllegalArgumentException.class, () ->
categorizer.categorize(new String[] {nbsp}));
+ assertThrows(IllegalArgumentException.class,
+ () -> categorizer.categorize(new String[] {null}));
+ }
+
+ @Test
+ void testConstructorRejectsNullInferenceOptions() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new DocumentCategorizerDL(null, null, vocab(), categories(),
+ new AverageClassificationScoringStrategy(), null));
+ }
+
@Test
void testTokenIdsMapsTokensToVocabularyIds() {
final long[] ids = DocumentCategorizerDL.tokenIds(
@@ -103,6 +124,27 @@ public class DocumentCategorizerDLTest {
"the error message should name the missing token: " + e.getMessage());
}
+ @Test
+ void testSoftmaxRejectsNaNLogit() {
+ // A NaN logit would otherwise poison the whole distribution into NaN
scores; fail loudly instead.
+ final IllegalStateException e = assertThrows(IllegalStateException.class,
() ->
+ DocumentCategorizerDL.softmax(new float[] {0f, Float.NaN, 0f}));
+ assertTrue(e.getMessage().contains("NaN"), e.getMessage());
+ }
+
+ @Test
+ void testSoftmaxRejectsInfiniteLogit() {
+ // A +Infinity logit (not NaN, so it slips past an isNaN-only guard)
poisons the distribution too:
+ // max becomes +Inf, so value - max is Inf - Inf == NaN, every exp() is
NaN, and categorize() would
+ // silently return all-NaN scores. It must fail loud like the NaN case.
-Infinity is non-finite too.
+ final IllegalStateException pos =
assertThrows(IllegalStateException.class, () ->
+ DocumentCategorizerDL.softmax(new float[] {0f,
Float.POSITIVE_INFINITY, 0f}));
+ assertTrue(pos.getMessage().contains("non-finite") ||
pos.getMessage().contains("Infinity"),
+ pos.getMessage());
+ assertThrows(IllegalStateException.class, () ->
+ DocumentCategorizerDL.softmax(new float[] {0f,
Float.NEGATIVE_INFINITY, 0f}));
+ }
+
@Test
void testSoftmaxIsUniformForEqualLogitsAndSumsToOne() {
final double[] out = DocumentCategorizerDL.softmax(new float[] {0f, 0f,
0f});
@@ -146,4 +188,34 @@ public class DocumentCategorizerDLTest {
assertEquals(0.24472847, out[1], 1e-6);
assertEquals(0.66524096, out[2], 1e-6);
}
+
+ @Test
+ void testLogitsFromOutputDispatchesOnModelShape() {
+ // A 2D output (BERT-style) takes row 0; a 1D output (RoBERTa-style) is
taken as-is.
+ assertArrayEquals(new float[] {1f, 2f},
+ DocumentCategorizerDL.logitsFromOutput(new float[][] {{1f, 2f}}));
+ assertArrayEquals(new float[] {3f, 4f},
+ DocumentCategorizerDL.logitsFromOutput(new float[] {3f, 4f}));
+ }
+
+ @Test
+ void testLogitsFromOutputFailsLoudlyOnNullAndUnexpectedType() {
+ // A null or otherwise-shaped model output is a contract violation, not an
"inference failed".
+ final IllegalStateException onNull =
assertThrows(IllegalStateException.class,
+ () -> DocumentCategorizerDL.logitsFromOutput(null));
+ assertTrue(onNull.getMessage().contains("null"), onNull.getMessage());
+ assertThrows(IllegalStateException.class,
+ () -> DocumentCategorizerDL.logitsFromOutput("not a tensor"));
+ }
+
+ @Test
+ void testRequireMatchingCategoryCountFailsLoudlyOnMismatch() {
+ // A distribution whose length differs from the configured category count
means the model and
+ // the categorizer configuration do not match; the matching case passes
the array through.
+ final double[] ok = {0.5, 0.5};
+ assertArrayEquals(ok,
DocumentCategorizerDL.requireMatchingCategoryCount(ok, 2));
+ final IllegalStateException e = assertThrows(IllegalStateException.class,
+ () -> DocumentCategorizerDL.requireMatchingCategoryCount(new double[]
{1.0}, 2));
+ assertTrue(e.getMessage().contains("do not match"), e.getMessage());
+ }
}
diff --git
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/namefinder/NameFinderDLTest.java
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/namefinder/NameFinderDLTest.java
index c0a8aede2..23db5e05c 100644
---
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/namefinder/NameFinderDLTest.java
+++
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/namefinder/NameFinderDLTest.java
@@ -17,6 +17,7 @@
package opennlp.dl.namefinder;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -28,6 +29,7 @@ import opennlp.tools.util.Span;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -169,7 +171,7 @@ public class NameFinderDLTest {
void testDecodeSpansLocatesEntityWithInternalPunctuation() {
// WordPiece splits "AT&T" into separate AT / & / T tokens, so the
reconstructed span text
// ("AT & T") must still be located in the contiguous source. Regression
guard for the
- // flexible-whitespace (\s*) matching in findByRegex.
+ // flexible-whitespace matching in findInSource (a span space matches zero
source whitespace).
final String text = "Buy AT&T stock";
final String[] tokens = {"[CLS]", "Buy", "AT", "&", "T", "stock", "[SEP]"};
final float[][] scores = {
@@ -184,6 +186,37 @@ public class NameFinderDLTest {
assertEquals("AT&T", spans.get(0).getCoveredText(text));
}
+ @Test
+ void testDecodeSpansMatchesEntitySeparatedByNoBreakSpace() {
+ // The source separates "New" and "York" with a no-break space (U+00A0).
Java's \s does not
+ // match it, so the previous regex matcher would have dropped this LOC
span; the Unicode-aware
+ // cursor matcher locates it and the covered text includes the no-break
space.
+ final String nbsp = new String(Character.toChars(0x00A0));
+ final String text = "Visit New" + nbsp + "York today";
+ final String[] tokens = {"[CLS]", "New", "York", "[SEP]"};
+ final float[][] scores = {scoresFor(0), scoresFor(3), scoresFor(4),
scoresFor(0)};
+
+ final List<Span> spans = NameFinderDL.decodeSpans(text, tokens, scores,
ID_TO_LABELS);
+
+ assertEquals(1, spans.size());
+ assertEquals("LOC", spans.get(0).getType());
+ assertEquals("New" + nbsp + "York", spans.get(0).getCoveredText(text));
+ }
+
+ @Test
+ void testDecodeSpansMatchesEntitySeparatedByIdeographicSpace() {
+ // Same idea with the CJK ideographic space (U+3000), another character
outside Java's \s.
+ final String ideographic = new String(Character.toChars(0x3000));
+ final String text = "from New" + ideographic + "York city";
+ final String[] tokens = {"[CLS]", "New", "York", "[SEP]"};
+ final float[][] scores = {scoresFor(0), scoresFor(3), scoresFor(4),
scoresFor(0)};
+
+ final List<Span> spans = NameFinderDL.decodeSpans(text, tokens, scores,
ID_TO_LABELS);
+
+ assertEquals(1, spans.size());
+ assertEquals("New" + ideographic + "York",
spans.get(0).getCoveredText(text));
+ }
+
@Test
void testDecodeSpansDoesNotMatchBeyondSearchEnd() {
final String text = "London was quiet. Later Paris was loud.";
@@ -199,7 +232,7 @@ public class NameFinderDLTest {
@Test
void testDecodeSpansMatchesSourceCaseInsensitively() {
// The reconstructed span text may differ in case from the source (e.g. an
uncased model);
- // findByRegex matches case-insensitively, so the span is still located at
the source offsets.
+ // findInSource matches case-insensitively, so the span is still located
at the source offsets.
final String text = "Visit PARIS today";
final String[] tokens = {"[CLS]", "Visit", "paris", "today", "[SEP]"};
final float[][] scores = {
@@ -324,6 +357,72 @@ public class NameFinderDLTest {
assertEquals(15, spans.get(1).getEnd());
}
+ @Test
+ void testMergeOverlappingSpansKeepsLongestAndPreservesDisjoint() {
+ // Containment: the longer span absorbs the shorter overlapping one.
+ final List<Span> contained = NameFinderDL.mergeOverlappingSpans(new
ArrayList<>(List.of(
+ new Span(0, 8, "LOC", 0.9), new Span(0, 13, "LOC", 0.8))));
+ assertEquals(1, contained.size());
+ assertEquals(0, contained.get(0).getStart());
+ assertEquals(13, contained.get(0).getEnd());
+
+ // Partial overlap: the longer span wins, the shorter overlapping one is
dropped.
+ final List<Span> partial = NameFinderDL.mergeOverlappingSpans(new
ArrayList<>(List.of(
+ new Span(0, 5, "LOC", 0.7), new Span(3, 12, "LOC", 0.6))));
+ assertEquals(1, partial.size());
+ assertEquals(3, partial.get(0).getStart());
+ assertEquals(12, partial.get(0).getEnd());
+
+ // Equal length overlap: the higher probability wins the tie.
+ final List<Span> tie = NameFinderDL.mergeOverlappingSpans(new
ArrayList<>(List.of(
+ new Span(0, 5, "PER", 0.6), new Span(2, 7, "PER", 0.9))));
+ assertEquals(1, tie.size());
+ assertEquals(2, tie.get(0).getStart());
+ assertEquals(7, tie.get(0).getEnd());
+
+ // Adjacent but disjoint spans are both kept and returned in document
order.
+ final List<Span> disjoint = NameFinderDL.mergeOverlappingSpans(new
ArrayList<>(List.of(
+ new Span(5, 10, "LOC", 0.9), new Span(0, 5, "PER", 0.9))));
+ assertEquals(2, disjoint.size());
+ assertEquals(0, disjoint.get(0).getStart());
+ assertEquals(5, disjoint.get(1).getStart());
+ }
+
+ @Test
+ void testMergeOverlappingSpansReturnsAFreshListForTrivialInput() {
+ // The size < 2 fast path must hand back a new list, not the caller's, so
the result is always
+ // owned by the caller -- the same ownership contract as the merging path.
+ final List<Span> single = new ArrayList<>(List.of(new Span(0, 5, "PER",
0.9)));
+ final List<Span> merged = NameFinderDL.mergeOverlappingSpans(single);
+ assertEquals(1, merged.size());
+ assertNotSame(single, merged);
+ assertTrue(NameFinderDL.mergeOverlappingSpans(new
ArrayList<Span>()).isEmpty());
+ }
+
+ @Test
+ void testChunkAssemblyKeepsFullerOverlappingSpan() {
+ // Mirrors how locate() decodes two overlapping chunks bounded to their
own character regions.
+ // Chunk 1 covers up to "York" and labels "New York"; the overlapping
chunk 2 covers "New York
+ // City" and labels it. Both candidates are produced and
mergeOverlappingSpans keeps the fuller
+ // "New York City" rather than dropping it, which is the chunk-boundary
case a single forward
+ // cursor mishandled.
+ final String text = "Alice visited New York City."; // "New York" =
[14,22), "City" ends at 27
+ final String[] chunk1 = {"[CLS]", "New", "York", "[SEP]"};
+ final float[][] scores1 = {scoresFor(0), scoresFor(3), scoresFor(4),
scoresFor(0)};
+ final String[] chunk2 = {"[CLS]", "New", "York", "City", "[SEP]"};
+ final float[][] scores2 =
+ {scoresFor(0), scoresFor(3), scoresFor(4), scoresFor(4), scoresFor(0)};
+
+ final List<Span> candidates = new ArrayList<>();
+ candidates.addAll(NameFinderDL.decodeSpans(text, chunk1, scores1,
ID_TO_LABELS, 14, 22));
+ candidates.addAll(NameFinderDL.decodeSpans(text, chunk2, scores2,
ID_TO_LABELS, 14, 27));
+ assertEquals(2, candidates.size(), "both chunks emit a candidate for the
boundary entity");
+
+ final List<Span> merged = NameFinderDL.mergeOverlappingSpans(candidates);
+ assertEquals(1, merged.size());
+ assertEquals("New York City", merged.get(0).getCoveredText(text));
+ }
+
@Test
void testDecodeSpansLocatesEntityWithRegexMetacharacters() {
// WordPiece splits "C++" into C / + / + tokens, so the reconstructed span
text contains regex
@@ -390,6 +489,14 @@ public class NameFinderDLTest {
assertBounded(p);
}
+ @Test
+ void testBuildSpanTextSkipsRobertaSpecialTokens() {
+ // RoBERTa markers (<s>, </s>) must be skipped during span reconstruction,
the same way the
+ // BERT [CLS]/[SEP] markers are, so they never leak into a reconstructed
entity span.
+ assertEquals("New York",
+ NameFinderDL.buildSpanText(new String[] {"<s>", "New", "York",
"</s>"}, 0, 3));
+ }
+
private static float[] scoresFor(int labelIndex) {
final float[] scores = new float[ID_TO_LABELS.size()];
for (int i = 0; i < scores.length; i++) {
@@ -425,4 +532,13 @@ public class NameFinderDLTest {
assertTrue(probability >= 0 && probability <= 1,
"probability must be within [0, 1]: " + probability);
}
+
+ @Test
+ void testConstructorRejectsNullArgumentsWithIllegalArgumentException() {
+ // The guards run in the constructor-argument expression, before any model
file is touched,
+ // so they are testable without an ONNX session; null parameters report
+ // IllegalArgumentException, matching the engine and tokenizer layers.
+ assertThrows(IllegalArgumentException.class,
+ () -> new NameFinderDL(null, null, null, null));
+ }
}
diff --git
a/opennlp-eval-tests/src/test/java/opennlp/dl/doccat/DocumentCategorizerDLEval.java
b/opennlp-eval-tests/src/test/java/opennlp/dl/doccat/DocumentCategorizerDLEval.java
index bf7002a06..5778b7438 100644
---
a/opennlp-eval-tests/src/test/java/opennlp/dl/doccat/DocumentCategorizerDLEval.java
+++
b/opennlp-eval-tests/src/test/java/opennlp/dl/doccat/DocumentCategorizerDLEval.java
@@ -239,7 +239,7 @@ public class DocumentCategorizerDLEval extends
AbstractEvalTest {
try (final DocumentCategorizerDL documentCategorizerDL =
new DocumentCategorizerDL(model, vocab, getCategories(),
new AverageClassificationScoringStrategy(),
- new InferenceOptions())) {
+ inferenceOptions)) {
final double[] result = documentCategorizerDL.categorize(new String[]
{"I am happy"});
logger.debug(Arrays.toString(result));
diff --git
a/opennlp-eval-tests/src/test/java/opennlp/dl/namefinder/NameFinderDLEval.java
b/opennlp-eval-tests/src/test/java/opennlp/dl/namefinder/NameFinderDLEval.java
index e19742b96..c22fce47d 100644
---
a/opennlp-eval-tests/src/test/java/opennlp/dl/namefinder/NameFinderDLEval.java
+++
b/opennlp-eval-tests/src/test/java/opennlp/dl/namefinder/NameFinderDLEval.java
@@ -407,6 +407,158 @@ public class NameFinderDLEval extends AbstractEvalTest {
}
+ /**
+ * End-to-end offset safety: with dash normalization enabled and a non-BMP
dash before an entity,
+ * the fold shrinks the text by one UTF-16 unit, so the entity sits at a
smaller offset in the
+ * normalized text than in the original. {@link
NameFinderDL#findInOriginal(String[])} must report
+ * the entity at its true offset in the original input, not the
one-unit-shorter normalized offset.
+ */
+ @Test
+ public void findInOriginalMapsSpansAcrossNonBmpDash() throws Exception {
+
+ final File model = new File(getOpennlpDataDir(),
"onnx/namefinder/model.onnx");
+ final File vocab = new File(getOpennlpDataDir(),
"onnx/namefinder/vocab.txt");
+
+ final InferenceOptions options = new InferenceOptions();
+ options.setNormalizeDashes(true);
+
+ // Yezidi hyphen (U+10EAD): a Unicode Dash code point in the supplementary
plane (two UTF-16
+ // units) that folds to a single ASCII hyphen, shifting every following
character left by one.
+ final String yezidi = new String(Character.toChars(0x10EAD));
+ final String[] tokens = {yezidi, "George", "Washington", "was",
"president",
+ "of", "the", "United", "States", "."};
+ final String original = String.join(" ", tokens);
+
+ try (final NameFinderDL nameFinderDL =
+ new NameFinderDL(model, vocab, getIds2Labels(), options,
sentenceDetector)) {
+
+ final Span[] spans = nameFinderDL.findInOriginal(tokens);
+
+ Span person = null;
+ for (final Span span : spans) {
+ if ("PER".equals(span.getType())) {
+ person = span;
+ }
+ }
+ Assertions.assertNotNull(person, "the PER entity should still be
detected after the dash");
+ // Mapped back through the alignment, the span covers the entity in the
ORIGINAL input (which
+ // still contains the two-unit dash); without the mapping it would be
shifted left by one.
+ Assertions.assertEquals("George Washington",
person.getCoveredText(original));
+ Assertions.assertEquals(original.indexOf("George Washington"),
person.getStart());
+ }
+
+ }
+
+ /**
+ * End-to-end chunk-boundary safety with the real model. With a small split
size and an overlap,
+ * "United States" straddles a chunk boundary: the first chunk ends at
"United" while the
+ * overlapping next chunk sees the full entity. The decoder must keep the
complete "United States"
+ * span rather than a truncated copy from whichever chunk decoded first, and
the output spans must
+ * not overlap.
+ */
+ @Test
+ public void findKeepsFullEntityAcrossChunkBoundary() throws Exception {
+
+ final File model = new File(getOpennlpDataDir(),
"onnx/namefinder/model.onnx");
+ final File vocab = new File(getOpennlpDataDir(),
"onnx/namefinder/vocab.txt");
+
+ final InferenceOptions options = new InferenceOptions();
+ // chunk 1 = tokens [0,7) (ends at "United"); chunk 2 = tokens [5,9) ("the
United States .").
+ options.setDocumentSplitSize(7);
+ options.setSplitOverlapSize(2);
+
+ final String[] tokens = {"George", "Washington", "was", "president",
+ "of", "the", "United", "States", "."};
+ final String text = String.join(" ", tokens);
+
+ try (final NameFinderDL nameFinderDL =
+ new NameFinderDL(model, vocab, getIds2Labels(), options,
sentenceDetector)) {
+
+ final Span[] spans = nameFinderDL.find(tokens);
+
+ boolean fullLocation = false;
+ boolean person = false;
+ for (final Span span : spans) {
+ if ("LOC".equals(span.getType()) && "United
States".equals(span.getCoveredText(text))) {
+ fullLocation = true;
+ }
+ if ("PER".equals(span.getType()) && "George
Washington".equals(span.getCoveredText(text))) {
+ person = true;
+ }
+ }
+ Assertions.assertTrue(fullLocation,
+ "the full 'United States' entity must survive the chunk boundary,
not a truncated copy");
+ Assertions.assertTrue(person, "the PER entity should still be detected");
+
+ assertNoOverlappingSpans(spans, text);
+ }
+
+ }
+
+ /**
+ * End-to-end de-duplication with the real model. With a generous overlap,
"United States" sits
+ * entirely within the shared region of two chunks and is decoded by both.
The decoder must report
+ * it once, not twice.
+ */
+ @Test
+ public void findDeduplicatesEntityDecodedInBothOverlappingChunks() throws
Exception {
+
+ final File model = new File(getOpennlpDataDir(),
"onnx/namefinder/model.onnx");
+ final File vocab = new File(getOpennlpDataDir(),
"onnx/namefinder/vocab.txt");
+
+ final InferenceOptions options = new InferenceOptions();
+ // chunk 1 = tokens [0,8); chunk 2 = tokens [4,9); "United States" (tokens
6,7) is in both.
+ options.setDocumentSplitSize(8);
+ options.setSplitOverlapSize(4);
+
+ final String[] tokens = {"George", "Washington", "was", "president",
+ "of", "the", "United", "States", "."};
+ final String text = String.join(" ", tokens);
+
+ try (final NameFinderDL nameFinderDL =
+ new NameFinderDL(model, vocab, getIds2Labels(), options,
sentenceDetector)) {
+
+ final Span[] spans = nameFinderDL.find(tokens);
+
+ int locations = 0;
+ for (final Span span : spans) {
+ if ("LOC".equals(span.getType()) && "United
States".equals(span.getCoveredText(text))) {
+ locations++;
+ }
+ }
+ Assertions.assertEquals(1, locations,
+ "the entity in the chunk overlap must be reported once, not
duplicated");
+
+ assertNoOverlappingSpans(spans, text);
+ }
+
+ }
+
+ // Asserts no two spans overlap in character coordinates, the post-merge
invariant of locate().
+ private static void assertNoOverlappingSpans(final Span[] spans, final
String text) {
+ for (int i = 0; i < spans.length; i++) {
+ for (int j = i + 1; j < spans.length; j++) {
+ Assertions.assertFalse(spans[i].intersects(spans[j]),
+ "spans must not overlap after merge: " +
spans[i].getCoveredText(text)
+ + " vs " + spans[j].getCoveredText(text));
+ }
+ }
+ }
+
+ @Test
+ public void findRejectsNullInput() throws Exception {
+ // Public entry points fail fast on a null token array rather than deeper
inside String.join.
+ final File model = new File(getOpennlpDataDir(),
"onnx/namefinder/model.onnx");
+ final File vocab = new File(getOpennlpDataDir(),
"onnx/namefinder/vocab.txt");
+
+ try (final NameFinderDL nameFinderDL =
+ new NameFinderDL(model, vocab, getIds2Labels(),
sentenceDetector)) {
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
nameFinderDL.find(null));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> nameFinderDL.findInOriginal(null));
+ }
+ }
+
private Map<Integer, String> getIds2Labels() {
final Map<Integer, String> ids2Labels = new HashMap<>();
diff --git
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
index 4c145a276..cf9f93213 100644
---
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
+++
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
@@ -196,7 +196,13 @@ public class SourceForgeModelEval extends AbstractEvalTest
{
digest.update(sentence.getBytes(StandardCharsets.UTF_8));
}
- Assertions.assertEquals(new
BigInteger("228544068397077998410949364710969159291"),
+ // Golden value updated for OPENNLP-205: the corrected Unicode White_Space
trimming in
+ // SentenceDetectorME.mapPositionsToSpans changes sentence boundaries at
the corpus
+ // positions where the Leipzig eng_news_2010_300K corpus contains
characters affected by
+ // the delta documented on SentenceDetectorME.WHITESPACE (NEL now trimmed,
U+001C..U+001F
+ // no longer trimmed). No other model in this suite invokes
SentenceDetectorME, and every
+ // other digest in this class is unchanged.
+ Assertions.assertEquals(new
BigInteger("75960100107764448986477802646419537130"),
new BigInteger(1, digest.digest()));
}