This is an automated email from the ASF dual-hosted git repository. krickert pushed a commit to branch feat/GH-1392-embed-padding in repository https://gitbox.apache.org/repos/asf/opennlp.git
commit 8c1bc0759f714ada6be8d342105a52306f5997be Author: Kristian Rickert <[email protected]> AuthorDate: Sun Oct 11 01:56:51 2026 -0400 GH-1392: Add padding strategies for embedAll embedAll grouped texts by exact tokenized length, so on natural text most inferences took one or two texts. With PaddingStrategy.LONGEST, texts are ordered by length and each batch is padded to its longest member. MAX_LENGTH pads all inputs to the maximum length, for models that need a fixed input size. EXACT_LENGTH remains the default and behaves as before. All strategies stay within 16384 token positions per inference. The onnx provider gets a padding option. The padding id comes from the vocabulary ([PAD], or <pad> for RoBERTa). Padded positions are 0 in the attention mask and mean pooling skips them, so vectors match up to floating-point rounding when the model honors the mask. batches is an instance method now, since it depends on the strategy. Without the batching change, the padding tests fail: testPaddingBatches: expected: <[[1, 2, 4, 0, 3]]> but was: <[[0, 3], [1, 2, 4]]> testPaddedBatchesRespectTheCap: expected: <[4096, 905]> but was: <[4096, 904, 1]> testPaddingIdIsReadFromTheVocabulary: array contents differ at index [0], expected: <10.0> but was: <7.0> tiny-masked.onnx sums the ids times the attention mask, so a test can tell a padded position with mask 0 from one with mask 1. It is generated by gen_tiny_masked_model.py next to it. Signed-off-by: Kristian Rickert <[email protected]> --- .../src/main/java/opennlp/dl/AbstractDL.java | 31 +- .../dl/vectors/OnnxTextEmbedderProvider.java | 48 ++- .../java/opennlp/dl/vectors/PaddingStrategy.java | 57 ++++ .../java/opennlp/dl/vectors/SentenceVectorsDL.java | 179 +++++++++-- .../dl/vectors/OnnxTextEmbedderProviderTest.java | 3 +- .../vectors/SentenceVectorsDLBatchLimitTest.java | 34 ++- .../dl/vectors/SentenceVectorsDLEmbedderTest.java | 157 +++++++++- .../vectors/SentenceVectorsDLPaddingEdgesTest.java | 337 +++++++++++++++++++++ .../java/opennlp/dl/vectors/TinyVectorModels.java | 54 +++- .../opennlp/dl/vectors/gen_tiny_masked_model.py | 102 +++++++ .../resources/opennlp/dl/vectors/tiny-masked.onnx | Bin 0 -> 603 bytes opennlp-docs/src/docbkx/machine-learning.xml | 15 +- rat-excludes | 1 + 13 files changed, 960 insertions(+), 58 deletions(-) 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 bb16ce410..63586d683 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 @@ -74,6 +74,10 @@ public abstract class AbstractDL implements AutoCloseable { private static final String WORDPIECE_SUBWORD_PREFIX = "##"; /** The first non-whitespace character of a vocabulary file that is read as JSON. */ private static final String JSON_OBJECT_START = "{"; + /** The padding token of a BERT vocabulary. */ + private static final String BERT_PAD_TOKEN = "[PAD]"; + /** The padding token of a RoBERTa vocabulary. */ + private static final String ROBERTA_PAD_TOKEN = "<pad>"; protected final OrtEnvironment env; protected final OrtSession session; @@ -408,13 +412,32 @@ public abstract class AbstractDL implements AutoCloseable { } /** - * The classification, separator and unknown tokens a tokenizer adds or emits. + * {@return the vocabulary id of the padding token} The padding token is {@code <pad>} for a + * RoBERTa vocabulary and {@code [PAD]} otherwise. + * + * @param vocab The vocabulary map. + * @throws IllegalArgumentException Thrown if the vocabulary has no padding token, or if it has + * the RoBERTa classification and separator tokens but no unknown token. + */ + protected static long padTokenId(final Map<String, Integer> vocab) { + final String padToken = specialTokens(vocab).pad(); + final Integer id = vocab.get(padToken); + if (id == null) { + throw new IllegalArgumentException("Padding needs a " + padToken + + " token, but the vocabulary has none."); + } + return id; + } + + /** + * The classification, separator, unknown and padding tokens of a vocabulary. * * @param cls The classification token. * @param sep The separator token. * @param unk The unknown token. + * @param pad The padding token. */ - private record SpecialTokens(String cls, String sep, String unk) { + private record SpecialTokens(String cls, String sep, String unk, String pad) { } /** @@ -430,10 +453,10 @@ public abstract class AbstractDL implements AutoCloseable { if (vocab.containsKey(WordpieceTokenizer.ROBERTA_CLS_TOKEN) && vocab.containsKey(WordpieceTokenizer.ROBERTA_SEP_TOKEN)) { return new SpecialTokens(WordpieceTokenizer.ROBERTA_CLS_TOKEN, - WordpieceTokenizer.ROBERTA_SEP_TOKEN, resolveUnknownToken(vocab)); + WordpieceTokenizer.ROBERTA_SEP_TOKEN, resolveUnknownToken(vocab), ROBERTA_PAD_TOKEN); } return new SpecialTokens(WordpieceTokenizer.BERT_CLS_TOKEN, - WordpieceTokenizer.BERT_SEP_TOKEN, WordpieceTokenizer.BERT_UNK_TOKEN); + WordpieceTokenizer.BERT_SEP_TOKEN, WordpieceTokenizer.BERT_UNK_TOKEN, BERT_PAD_TOKEN); } /** diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/OnnxTextEmbedderProvider.java b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/OnnxTextEmbedderProvider.java index a07da704e..1fbceeb3d 100644 --- a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/OnnxTextEmbedderProvider.java +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/OnnxTextEmbedderProvider.java @@ -19,22 +19,25 @@ package opennlp.dl.vectors; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Locale; +import java.util.ArrayList; +import java.util.List; import ai.onnxruntime.OrtEnvironment; import ai.onnxruntime.OrtException; +import opennlp.dl.InferenceOptions; import opennlp.tools.embeddings.TextEmbedder; import opennlp.tools.embeddings.TextEmbedderProvider; import opennlp.tools.util.ParamChecks; +import opennlp.tools.util.StringUtil; import opennlp.tools.util.ext.ProviderSpec; /** * Provides a {@link SentenceVectorsDL} under the name {@value #NAME}. It supports a spec whose * location is a local file named {@code *.onnx}, in any letter case, and whose only options are * {@value #VOCABULARY_OPTION}, {@value #LOWER_CASE_OPTION}, {@value #POOLING_OPTION}, - * {@value #NORMALIZE_OPTION} and {@value #MAX_LENGTH_OPTION}. The option values are checked by - * {@link #create(ProviderSpec)}, which also initializes the ONNX Runtime. + * {@value #NORMALIZE_OPTION}, {@value #MAX_LENGTH_OPTION} and {@value #PADDING_OPTION}. The option + * values are checked by {@link #create(ProviderSpec)}, which also initializes the ONNX Runtime. * * @since 3.0.0 */ @@ -64,11 +67,17 @@ public final class OnnxTextEmbedderProvider implements TextEmbedderProvider { */ public static final String MAX_LENGTH_OPTION = "maxLength"; + /** + * The option that selects the {@link PaddingStrategy}: {@code exact_length} by default, + * {@code longest} or {@code max_length}. + */ + public static final String PADDING_OPTION = "padding"; + private static final String MODEL_SUFFIX = ".onnx"; private static final String TRUE = "true"; private static final String FALSE = "false"; - private static final String MEAN = Pooling.MEAN.name().toLowerCase(Locale.ROOT); - private static final String CLS = Pooling.CLS.name().toLowerCase(Locale.ROOT); + private static final String MEAN = StringUtil.toLowerCase(Pooling.MEAN.name()); + private static final String CLS = StringUtil.toLowerCase(Pooling.CLS.name()); private static final int MIN_MAX_LENGTH = 2; @Override @@ -94,7 +103,7 @@ public final class OnnxTextEmbedderProvider implements TextEmbedderProvider { ParamChecks.requireNonNullArg(spec, "spec"); return spec.path().isPresent() && spec.locationEndsWith(MODEL_SUFFIX) && spec.hasOnlyOptions(VOCABULARY_OPTION, LOWER_CASE_OPTION, POOLING_OPTION, - NORMALIZE_OPTION, MAX_LENGTH_OPTION); + NORMALIZE_OPTION, MAX_LENGTH_OPTION, PADDING_OPTION); } @Override @@ -112,10 +121,11 @@ public final class OnnxTextEmbedderProvider implements TextEmbedderProvider { final boolean normalize = booleanOption(spec, NORMALIZE_OPTION); final Pooling pooling = poolingOption(spec); final int maxLength = maxLengthOption(spec); + final PaddingStrategy padding = paddingOption(spec); final Path vocabularyPath = model.toAbsolutePath().getParent().resolve(vocabulary); try { return new SentenceVectorsDL(model.toFile(), vocabularyPath.toFile(), lowerCase, pooling, - normalize, maxLength); + normalize, maxLength, padding, new InferenceOptions()); } catch (final OrtException e) { throw new IOException("Cannot load the ONNX model " + model, e); } catch (final LinkageError e) { @@ -159,6 +169,30 @@ public final class OnnxTextEmbedderProvider implements TextEmbedderProvider { throw new IllegalArgumentException(POOLING_OPTION + " must be " + MEAN + " or " + CLS); } + /** + * Reads the {@value #PADDING_OPTION} option. + * + * @param spec The spec to read. + * @return The padding strategy, {@link PaddingStrategy#EXACT_LENGTH} if the option is not set. + * @throws IllegalArgumentException Thrown if the value is not the lower case name of a + * {@link PaddingStrategy}. + */ + private PaddingStrategy paddingOption(final ProviderSpec spec) { + final String value = spec.option(PADDING_OPTION, null); + if (value == null) { + return SentenceVectorsDL.DEFAULT_PADDING; + } + final List<String> names = new ArrayList<>(); + for (final PaddingStrategy padding : PaddingStrategy.values()) { + final String name = StringUtil.toLowerCase(padding.name()); + if (name.equals(value)) { + return padding; + } + names.add(name); + } + throw new IllegalArgumentException(PADDING_OPTION + " must be one of " + names); + } + /** * Reads the {@value #MAX_LENGTH_OPTION} option. * diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/PaddingStrategy.java b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/PaddingStrategy.java new file mode 100644 index 000000000..fba022ec5 --- /dev/null +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/PaddingStrategy.java @@ -0,0 +1,57 @@ +/* + * 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.vectors; + +import java.util.List; + +/** + * How {@link SentenceVectorsDL#embedAll(List)} shapes the tensors of one inference when the texts + * of a call differ in tokenized length. + * + * <p>A padded position is {@code 0} in the attention mask and {@link Pooling} reads only the + * positions a row's own mask covers, so the vectors of all three strategies match up to + * floating-point rounding, provided the model honors the attention mask. A + * {@code sentence_embedding} output that ignores the mask changes with padding. The strategies + * differ in tensor shape and in how many times the session runs.</p> + * + * @since 3.0.0 + */ +public enum PaddingStrategy { + + /** + * No padding. Texts are grouped by their exact tokenized length and each group runs on its own. + * This is the default. On natural text most groups hold one or two texts. + */ + EXACT_LENGTH, + + /** + * Every row of a batch is padded to the longest row of that batch. Texts are ordered by length + * before batching, so one long text does not pad out a batch of short ones. Requires a padding + * token in the vocabulary. + */ + LONGEST, + + /** + * Every row is padded to the maximum length, so every inference has the same shape. Single + * calls such as {@link SentenceVectorsDL#embed(CharSequence)} and + * {@link SentenceVectorsDL#getVectors(String)} pad to the maximum length as well. Useful for a + * model or accelerator that needs a fixed sequence length. Requires a padding token in the + * vocabulary. + */ + MAX_LENGTH +} diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java index 290f1a74c..031ef2b51 100644 --- a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java @@ -22,6 +22,8 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.LongBuffer; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -74,9 +76,10 @@ import opennlp.tools.util.ParamChecks; * This thread-safety guarantee applies until {@link #close()} is called; callers must not race * {@code close()} with inference methods.</p> * - * <p>{@link #embedAll(List)} runs one batched session per distinct tokenized length, so a batch - * of same-length inputs costs one inference instead of one per input. A group larger than - * {@value #MAX_BATCH_TOKEN_POSITIONS} token positions is split into several inferences.</p> + * <p>{@link #embedAll(List)} batches its texts according to the configured + * {@link PaddingStrategy}. By default it does not pad and runs one inference per distinct + * tokenized length; {@link PaddingStrategy#LONGEST} runs texts of mixed lengths together. No + * inference exceeds {@value #MAX_BATCH_TOKEN_POSITIONS} token positions.</p> */ @ThreadSafe public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { @@ -92,6 +95,9 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { */ static final int MAX_BATCH_TOKEN_POSITIONS = 16384; + /** The {@link PaddingStrategy} used if none is given. */ + public static final PaddingStrategy DEFAULT_PADDING = PaddingStrategy.EXACT_LENGTH; + private static final String SENTENCE_EMBEDDING = "sentence_embedding"; private static final int POOLED_RANK = 2; private static final int TOKEN_RANK = 3; @@ -103,6 +109,8 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { private final String outputName; private final boolean pooledOutput; private final int dimension; + private final PaddingStrategy padding; + private final long padTokenId; /** * Instantiates a {@link SentenceVectorsDL sentence vector generator} for an @@ -195,7 +203,8 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { * @param maxLength The maximum number of tokens per input, {@code [CLS]} and {@code [SEP]} * included; longer input is truncated. Must be at least {@code 2}. * @param inferenceOptions The {@link InferenceOptions}, of which the GPU settings and the lower - * case setting are used. Must not be {@code null}. + * case setting are used. Must not be {@code null}. {@link #embedAll(List)} batches by + * {@link #DEFAULT_PADDING}. * * @throws IllegalArgumentException Thrown if {@code pooling} or {@code inferenceOptions} is * {@code null}, if {@code inferenceOptions} holds invalid split options, if @@ -215,16 +224,61 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { final InferenceOptions inferenceOptions) throws OrtException, IOException { + this(model, vocabulary, lowerCase, pooling, normalize, maxLength, DEFAULT_PADDING, + inferenceOptions); + + } + + /** + * Instantiates a {@link SentenceVectorsDL sentence vector generator} using ONNX models, with + * the {@link PaddingStrategy} that {@link #embedAll(List)} batches by and the + * {@link InferenceOptions} the session is built from. + * + * @param model The file name of a sentence vectors ONNX model. + * @param vocabulary The file name of the vocabulary file for the model. + * @param lowerCase {@code true} for uncased models (lower casing and accent + * stripping during tokenization), {@code false} for cased models. A lower case setting in + * {@code inferenceOptions} takes precedence. + * @param pooling How token vectors are pooled. Not used for a model with a + * {@code sentence_embedding} output. Must not be {@code null}. + * @param normalize {@code true} to scale every vector to unit length. + * @param maxLength The maximum number of tokens per input, {@code [CLS]} and {@code [SEP]} + * included; longer input is truncated. Must be at least {@code 2}. + * @param padding How {@link #embedAll(List)} batches texts of different lengths. Must not be + * {@code null}. Every strategy but {@link PaddingStrategy#EXACT_LENGTH} needs a + * {@code [PAD]} token in the vocabulary, or {@code <pad>} for a RoBERTa vocabulary. + * @param inferenceOptions The {@link InferenceOptions}, of which the GPU settings and the lower + * case setting are used. Must not be {@code null}. + * + * @throws IllegalArgumentException Thrown if {@code pooling}, {@code padding} or + * {@code inferenceOptions} is {@code null}, if {@code inferenceOptions} contains invalid + * split options, if {@code maxLength} is less than {@code 2}, if the model has no output of + * shape {@code [batch, hidden]} or {@code [batch, tokens, hidden]}, or if {@code padding} + * pads and the vocabulary has no padding token. + * @throws OrtException Thrown if the {@code model} cannot be loaded, or if a GPU was requested + * and ONNX Runtime cannot provide it. ONNX Runtime does not fall back to the CPU. + * @throws IOException Thrown if errors occurred loading the {@code model} or {@code vocabulary}. + * + * @since 3.0.0 + */ + public SentenceVectorsDL(final File model, final File vocabulary, final boolean lowerCase, + final Pooling pooling, final boolean normalize, final int maxLength, + final PaddingStrategy padding, final InferenceOptions inferenceOptions) + throws OrtException, IOException { + super(model, vocabulary, sessionOptions(inferenceOptions), resolveLowerCase(inferenceOptions, lowerCase)); try { ParamChecks.requireNonNullArg(pooling, "pooling"); + ParamChecks.requireNonNullArg(padding, "padding"); if (maxLength < MIN_LENGTH) { throw new IllegalArgumentException("maxLength must be at least " + MIN_LENGTH); } this.pooling = pooling; this.normalize = normalize; this.maxLength = maxLength; + this.padding = padding; + this.padTokenId = padding == PaddingStrategy.EXACT_LENGTH ? 0 : padTokenId(vocab); final Map<String, NodeInfo> outputs = session.getOutputInfo(); this.outputName = selectOutput(outputs); final long[] shape = ((TensorInfo) outputs.get(outputName).getInfo()).getShape(); @@ -278,8 +332,12 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { /** * {@inheritDoc} * - * <p>The inputs are grouped by tokenized length, never padded, and each group runs in - * inferences of at most {@value #MAX_BATCH_TOKEN_POSITIONS} token positions.</p> + * <p>The inputs are tokenized up front and batched according to the configured + * {@link PaddingStrategy}, so that no inference exceeds {@value #MAX_BATCH_TOKEN_POSITIONS} + * token positions. A padded position is {@code 0} in the attention mask, so vector {@code i} + * matches what {@link #embed(CharSequence)} returns for input {@code i} up to floating-point + * rounding, provided the model honors the attention mask. A {@code sentence_embedding} output + * that ignores the mask changes with padding.</p> */ @Override public float[][] embedAll(final List<? extends CharSequence> texts) { @@ -314,29 +372,69 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { } /** - * Splits the indices of a call into the batches that run as single inferences. A batch contains - * indices of one tokenized length, in call order, and no more rows than fit in - * {@value #MAX_BATCH_TOKEN_POSITIONS} token positions, but always at least one. + * Splits the indices of a call into the batches that run as single inferences. No batch + * exceeds {@value #MAX_BATCH_TOKEN_POSITIONS} token positions, but each has at least one row. + * + * <p>Under {@link PaddingStrategy#EXACT_LENGTH} a batch contains indices of one tokenized length, + * in call order. Under {@link PaddingStrategy#MAX_LENGTH} every row is the maximum length wide, + * so batches are consecutive indices in call order. Under {@link PaddingStrategy#LONGEST} the + * indices are ordered by tokenized length and cut where the next row would exceed the cap.</p> * * @param encoded The encodings of the call, in call order. * @return The batches, each a list of indices into {@code encoded}. */ - static List<List<Integer>> batches(final Tokens[] encoded) { - final Map<Integer, List<Integer>> byLength = new LinkedHashMap<>(); + List<List<Integer>> batches(final Tokens[] encoded) { + final List<List<Integer>> batches = new ArrayList<>(); + if (padding == PaddingStrategy.EXACT_LENGTH) { + final Map<Integer, List<Integer>> byLength = new LinkedHashMap<>(); + for (int i = 0; i < encoded.length; i++) { + byLength.computeIfAbsent(encoded[i].ids().length, length -> new ArrayList<>()).add(i); + } + for (final Map.Entry<Integer, List<Integer>> group : byLength.entrySet()) { + addCapped(batches, group.getValue(), group.getKey()); + } + return batches; + } + final List<Integer> order = new ArrayList<>(encoded.length); for (int i = 0; i < encoded.length; i++) { - byLength.computeIfAbsent(encoded[i].ids().length, length -> new ArrayList<>()).add(i); + order.add(i); } - final List<List<Integer>> batches = new ArrayList<>(); - for (final Map.Entry<Integer, List<Integer>> group : byLength.entrySet()) { - final int rows = Math.max(1, MAX_BATCH_TOKEN_POSITIONS / group.getKey()); - final List<Integer> indices = group.getValue(); - for (int from = 0; from < indices.size(); from += rows) { - batches.add(indices.subList(from, Math.min(from + rows, indices.size()))); + if (padding == PaddingStrategy.MAX_LENGTH) { + addCapped(batches, order, maxLength); + return batches; + } + order.sort(Comparator.comparingInt(i -> encoded[i].ids().length)); + int from = 0; + for (int i = 0; i < order.size(); i++) { + // Sorted ascending, so the row being added is the longest of its batch. + final long width = encoded[order.get(i)].ids().length; + if (i > from && (i - from + 1) * width > MAX_BATCH_TOKEN_POSITIONS) { + batches.add(order.subList(from, i)); + from = i; } } + if (from < order.size()) { + batches.add(order.subList(from, order.size())); + } return batches; } + /** + * Adds indices that all run at one row width as consecutive batches of at most + * {@value #MAX_BATCH_TOKEN_POSITIONS} token positions, and at least one row. + * + * @param batches The batches to add to. + * @param indices The indices to split, in the order they run. + * @param width The row width they run at. + */ + private static void addCapped(final List<List<Integer>> batches, final List<Integer> indices, + final int width) { + final int rows = Math.max(1, MAX_BATCH_TOKEN_POSITIONS / width); + for (int from = 0; from < indices.size(); from += rows) { + batches.add(indices.subList(from, Math.min(from + rows, indices.size()))); + } + } + /** * {@inheritDoc} * @@ -387,23 +485,27 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { } /** - * Runs one inference over encodings of the same length and returns one sentence vector per - * encoding, in order. + * Runs one inference over a batch of encodings and returns one sentence vector per encoding, in + * order. Rows shorter than the batch width are padded: {@code input_ids} with the padding + * token id, {@code attention_mask} and {@code token_type_ids} with {@code 0}. * - * @param batch The encodings, all of the same length. + * @param batch The encodings, of any lengths. * @return The sentence vectors. * @throws OrtException Thrown if an error occurs during inference. */ private float[][] run(final Tokens[] batch) throws OrtException { - final int length = batch[0].ids().length; + final int length = width(batch); final long[] ids = new long[batch.length * length]; final long[] mask = new long[batch.length * length]; final long[] types = new long[batch.length * length]; for (int b = 0; b < batch.length; b++) { - System.arraycopy(batch[b].ids(), 0, ids, b * length, length); - System.arraycopy(batch[b].mask(), 0, mask, b * length, length); - System.arraycopy(batch[b].types(), 0, types, b * length, length); + final int rowLength = batch[b].ids().length; + System.arraycopy(batch[b].ids(), 0, ids, b * length, rowLength); + System.arraycopy(batch[b].mask(), 0, mask, b * length, rowLength); + System.arraycopy(batch[b].types(), 0, types, b * length, rowLength); + // mask and types stay 0 past the row; only the ids need the padding token id. + Arrays.fill(ids, b * length + rowLength, (b + 1) * length, padTokenId); } final Map<String, OnnxTensor> inputs = new HashMap<>(); @@ -438,10 +540,29 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { } /** - * Pools the token vectors of one input into its sentence vector. + * {@return the row width of one inference} It is the maximum length under + * {@link PaddingStrategy#MAX_LENGTH}, otherwise the longest row of the batch. + * + * @param batch The encodings of one inference, at least one. + */ + private int width(final Tokens[] batch) { + if (padding == PaddingStrategy.MAX_LENGTH) { + return maxLength; + } + int longest = 0; + for (final Tokens row : batch) { + longest = Math.max(longest, row.ids().length); + } + return longest; + } + + /** + * Pools the token vectors of one input into its sentence vector. Only the first + * {@code mask.length} positions are read, so padding after the row is ignored. * - * @param tokenVectors The vectors of the input's tokens. - * @param mask The attention mask of the input. + * @param tokenVectors The vectors of the input's tokens, at least as many as {@code mask} is + * long. + * @param mask The attention mask of the input, without padding. * @return A new array holding the sentence vector. */ private float[] pool(final float[][] tokenVectors, final long[] mask) { @@ -450,7 +571,7 @@ public class SentenceVectorsDL extends AbstractDL implements TextEmbedder { } final float[] sum = new float[tokenVectors[0].length]; int count = 0; - for (int t = 0; t < tokenVectors.length; t++) { + for (int t = 0; t < mask.length; t++) { if (mask[t] != 0) { for (int d = 0; d < sum.length; d++) { sum[d] += tokenVectors[t][d]; diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/OnnxTextEmbedderProviderTest.java b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/OnnxTextEmbedderProviderTest.java index 45625292e..e57bb8632 100644 --- a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/OnnxTextEmbedderProviderTest.java +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/OnnxTextEmbedderProviderTest.java @@ -32,6 +32,7 @@ import static opennlp.dl.vectors.OnnxTextEmbedderProvider.LOWER_CASE_OPTION; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.MAX_LENGTH_OPTION; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.NAME; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.NORMALIZE_OPTION; +import static opennlp.dl.vectors.OnnxTextEmbedderProvider.PADDING_OPTION; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.POOLING_OPTION; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.VOCABULARY_OPTION; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -63,7 +64,7 @@ class OnnxTextEmbedderProviderTest { Map.of(VOCABULARY_OPTION, "vocab.txt", LOWER_CASE_OPTION, "false")))); assertTrue(provider.supports(ProviderSpec.of(Path.of(model), Map.of(VOCABULARY_OPTION, "vocab.txt", POOLING_OPTION, "cls", NORMALIZE_OPTION, "false", - MAX_LENGTH_OPTION, "256")))); + MAX_LENGTH_OPTION, "256", PADDING_OPTION, "longest")))); } @ParameterizedTest diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLBatchLimitTest.java b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLBatchLimitTest.java index 01c5b03c2..5b27037a3 100644 --- a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLBatchLimitTest.java +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLBatchLimitTest.java @@ -24,6 +24,8 @@ import java.util.Collections; import java.util.List; import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -38,6 +40,7 @@ import static opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS; import static opennlp.dl.vectors.TinyVectorModels.gpuOrAbort; import static opennlp.dl.vectors.TinyVectorModels.model; import static opennlp.dl.vectors.TinyVectorModels.scale; +import static opennlp.dl.vectors.TinyVectorModels.tokens; import static opennlp.dl.vectors.TinyVectorModels.vocab; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -76,13 +79,28 @@ class SentenceVectorsDLBatchLimitTest { maxLength); } + /** The directory of the shared embedder. */ + @TempDir + static Path shared; + + /** An embedder that does not pad, whose batches the tests below check. */ + private static SentenceVectorsDL exact; + /** - * {@return an encoding of {@code length} tokens; only its length matters for batching} + * Opens the shared embedder. * - * @param length The number of tokens. + * @throws Exception Thrown if the model cannot be loaded. */ - private static Tokens tokens(final int length) { - return new Tokens(new String[length], new long[length], new long[length], new long[length]); + @BeforeAll + static void openExact() throws Exception { + exact = vectors(shared, TINY_VECTORS, Pooling.MEAN, false, + SentenceVectorsDL.DEFAULT_MAX_LENGTH); + } + + /** Closes the shared embedder. */ + @AfterAll + static void closeExact() { + exact.close(); } /** @@ -94,7 +112,7 @@ class SentenceVectorsDLBatchLimitTest { private static List<Integer> sizes(final int length, final int count) { final Tokens[] encoded = Collections.nCopies(count, tokens(length)).toArray(new Tokens[0]); final List<Integer> sizes = new ArrayList<>(); - for (final List<Integer> batch : SentenceVectorsDL.batches(encoded)) { + for (final List<Integer> batch : exact.batches(encoded)) { sizes.add(batch.size()); } return sizes; @@ -152,13 +170,13 @@ class SentenceVectorsDLBatchLimitTest { /** An empty call has no batches. */ @Test void testEmptyCallHasNoBatches() { - assertTrue(SentenceVectorsDL.batches(new Tokens[0]).isEmpty()); + assertTrue(exact.batches(new Tokens[0]).isEmpty()); } /** Inputs of different lengths run one batch per length, in the order each length appears. */ @Test void testGroupsByLengthInCallOrder() { - assertEquals(List.of(List.of(0, 3), List.of(1, 2, 4)), SentenceVectorsDL.batches( + assertEquals(List.of(List.of(0, 3), List.of(1, 2, 4)), exact.batches( new Tokens[] {tokens(4), tokens(3), tokens(3), tokens(4), tokens(3)})); } @@ -172,7 +190,7 @@ class SentenceVectorsDLBatchLimitTest { Arrays.fill(encoded, tokens(4)); encoded[1] = tokens(3); encoded[2] = tokens(3); - final List<List<Integer>> batches = SentenceVectorsDL.batches(encoded); + final List<List<Integer>> batches = exact.batches(encoded); assertEquals(3, batches.size()); assertEquals(LIMIT / 4, batches.get(0).size()); assertEquals(List.of(0, 3), batches.get(0).subList(0, 2)); diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java index d12d97878..61198e668 100644 --- a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java @@ -21,13 +21,19 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import opennlp.dl.InferenceOptions; +import opennlp.dl.Tokens; import opennlp.tools.embeddings.TextEmbedder; import opennlp.tools.embeddings.TextEmbedderProvider; import opennlp.tools.util.InvalidFormatException; @@ -37,13 +43,16 @@ import opennlp.tools.util.ext.Providers; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.LOWER_CASE_OPTION; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.MAX_LENGTH_OPTION; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.NORMALIZE_OPTION; +import static opennlp.dl.vectors.OnnxTextEmbedderProvider.PADDING_OPTION; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.POOLING_OPTION; import static opennlp.dl.vectors.OnnxTextEmbedderProvider.VOCABULARY_OPTION; import static opennlp.dl.vectors.TinyVectorModels.TINY_POOLED; import static opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS; import static opennlp.dl.vectors.TinyVectorModels.model; import static opennlp.dl.vectors.TinyVectorModels.scale; +import static opennlp.dl.vectors.TinyVectorModels.tokens; import static opennlp.dl.vectors.TinyVectorModels.vocab; +import static opennlp.dl.vectors.TinyVectorModels.vocabPad6; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; @@ -88,7 +97,14 @@ class SentenceVectorsDLEmbedderTest { private static SentenceVectorsDL meanVectors(final Path dir, final int maxLength) throws Exception { - return new SentenceVectorsDL(model(dir, TINY_VECTORS), vocab(dir), true, Pooling.MEAN, false, maxLength); + return new SentenceVectorsDL(model(dir, TINY_VECTORS), vocab(dir), true, Pooling.MEAN, false, + maxLength); + } + + private static SentenceVectorsDL meanVectors(final Path dir, final int maxLength, + final PaddingStrategy padding) throws Exception { + return new SentenceVectorsDL(model(dir, TINY_VECTORS), vocab(dir), true, Pooling.MEAN, false, + maxLength, padding, new InferenceOptions()); } @Test @@ -111,6 +127,13 @@ class SentenceVectorsDLEmbedderTest { try (TextEmbedder cls = provider.create(clsSpec)) { assertArrayEquals(CLS_VECTOR, cls.embed("hello world"), DELTA); } + for (final String padding : List.of("exact_length", "longest", "max_length")) { + try (TextEmbedder padded = provider.create(ProviderSpec.of(graph, + Map.of(VOCABULARY_OPTION, "vocab.txt", PADDING_OPTION, padding)))) { + assertArrayEquals(UNIT_VECTOR, padded.embedAll(List.of("hello world", "hello"))[1], + DELTA, padding); + } + } assertThrows(IllegalArgumentException.class, () -> provider.create(null)); assertThrows(IllegalArgumentException.class, () -> provider.create(ProviderSpec.of(dir, options)), "a directory"); @@ -122,7 +145,8 @@ class SentenceVectorsDLEmbedderTest { for (final Map.Entry<String, String> invalid : List.of(Map.entry(LOWER_CASE_OPTION, "invalid"), Map.entry(NORMALIZE_OPTION, "yes"), Map.entry(POOLING_OPTION, "max"), Map.entry(POOLING_OPTION, "MEAN"), Map.entry(MAX_LENGTH_OPTION, "1"), - Map.entry(MAX_LENGTH_OPTION, "many"), Map.entry("typo", "true"))) { + Map.entry(MAX_LENGTH_OPTION, "many"), Map.entry(PADDING_OPTION, "none"), + Map.entry(PADDING_OPTION, "LONGEST"), Map.entry("typo", "true"))) { assertThrows(IllegalArgumentException.class, () -> provider.create(ProviderSpec.of(graph, Map.of(VOCABULARY_OPTION, "vocab.txt", invalid.getKey(), invalid.getValue()))), invalid.toString()); @@ -248,6 +272,135 @@ class SentenceVectorsDLEmbedderTest { } } + /** + * {@return the number of rows of each batch} + * + * @param batches The batches. + */ + private static List<Integer> sizes(final List<List<Integer>> batches) { + return batches.stream().map(List::size).toList(); + } + + /** + * Asserts that every padding strategy returns, for each input of a mixed-length call, the + * vector that input gets on its own: padded positions are masked out of the pooling. + */ + @ParameterizedTest + @EnumSource(PaddingStrategy.class) + void testPaddingKeepsSingleInputVectors(final PaddingStrategy padding, @TempDir final Path dir) + throws Exception { + try (SentenceVectorsDL vectors = meanVectors(dir, 6, padding)) { + final List<String> texts = List.of("hello world", "hello", "world", "hello world", + "hello", ""); + final float[][] batch = vectors.embedAll(texts); + for (int i = 0; i < texts.size(); i++) { + assertArrayEquals(vectors.embed(texts.get(i)), batch[i], DELTA, texts.get(i)); + } + assertArrayEquals(HELLO_WORLD_MEAN, batch[0], DELTA); + assertArrayEquals(HELLO_MEAN, batch[1], DELTA); + // "world" = [CLS]=7 world=5 [SEP]=3, mean 5. + assertArrayEquals(scale(5), batch[2], DELTA); + assertArrayEquals(HELLO_WORLD_MEAN, batch[3], DELTA); + assertArrayEquals(HELLO_MEAN, batch[4], DELTA); + // "" = [CLS]=7 [SEP]=3, mean 5. + assertArrayEquals(scale(5), batch[5], DELTA); + } + } + + /** + * Asserts the batches of each strategy for one mixed-length call: one per length without + * padding, otherwise one holding every row, ordered by length under LONGEST. + */ + @Test + void testPaddingBatches(@TempDir final Path dir) throws Exception { + final Tokens[] rows = {tokens(4), tokens(3), tokens(3), tokens(4), tokens(3)}; + try (SentenceVectorsDL exact = meanVectors(dir, 6, PaddingStrategy.EXACT_LENGTH); + SentenceVectorsDL longest = meanVectors(dir, 6, PaddingStrategy.LONGEST); + SentenceVectorsDL fixed = meanVectors(dir, 6, PaddingStrategy.MAX_LENGTH)) { + assertEquals(List.of(List.of(0, 3), List.of(1, 2, 4)), exact.batches(rows)); + assertEquals(List.of(List.of(1, 2, 4, 0, 3)), longest.batches(rows)); + assertEquals(List.of(List.of(0, 1, 2, 3, 4)), fixed.batches(rows)); + } + } + + /** + * Asserts that padded batches also respect the token position cap. LONGEST orders the inputs + * by length, so the one short input shares the first batch with the long ones. + */ + @Test + void testPaddedBatchesRespectTheCap(@TempDir final Path dir) throws Exception { + final List<String> texts = new ArrayList<>(Collections.nCopies(5000, + "hello world")); + texts.add("hello"); + final List<Tokens> encoded = new ArrayList<>(Collections.nCopies(5000, tokens(4))); + encoded.add(tokens(3)); + final Tokens[] rows = encoded.toArray(new Tokens[0]); + try (SentenceVectorsDL longest = meanVectors(dir, 8, PaddingStrategy.LONGEST); + SentenceVectorsDL fixed = meanVectors(dir, 8, PaddingStrategy.MAX_LENGTH)) { + final List<List<Integer>> longestBatches = longest.batches(rows); + assertEquals(List.of(4096, 905), sizes(longestBatches)); + assertEquals(5000, longestBatches.get(0).get(0)); + assertEquals(List.of(2048, 2048, 905), sizes(fixed.batches(rows))); + final float[][] batch = longest.embedAll(texts); + assertArrayEquals(HELLO_WORLD_MEAN, batch[4999], DELTA); + assertArrayEquals(HELLO_MEAN, batch[5000], DELTA); + } + } + + /** + * Asserts that the padding id comes from the vocabulary. {@code tiny-pooled.onnx} sums the ids + * of every position without reading the mask, so a padded row shows the id that was placed. + */ + @Test + void testPaddingIdIsReadFromTheVocabulary(@TempDir final Path dir) throws Exception { + try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, "tiny-pooled.onnx"), + vocabPad6(dir), true, Pooling.MEAN, false, 8, PaddingStrategy.LONGEST, + new InferenceOptions())) { + final float[][] batch = vectors.embedAll(List.of("hello world", "hello")); + assertArrayEquals(HELLO_WORLD_SUM, batch[0], DELTA); + assertArrayEquals(scale(14 + 6), batch[1], DELTA); + } + } + + /** + * Asserts that padded positions are {@code 0} in the attention mask. {@code tiny-masked.onnx} + * sums the ids times the mask, so a padded row gives the sum of its own ids only; a mask of + * {@code 1} at the padded position would add the padding id {@code 6}. + */ + @ParameterizedTest + @EnumSource(value = PaddingStrategy.class, names = {"LONGEST", "MAX_LENGTH"}) + void testPaddedPositionsAreMaskedOut(final PaddingStrategy padding, @TempDir final Path dir) + throws Exception { + try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, "tiny-masked.onnx"), + vocabPad6(dir), true, Pooling.MEAN, false, 8, padding, new InferenceOptions())) { + final float[][] batch = vectors.embedAll(List.of("hello world", "hello")); + assertArrayEquals(HELLO_WORLD_SUM, batch[0], DELTA); + assertArrayEquals(scale(14), batch[1], DELTA); + } + } + + /** + * Asserts that a padding strategy needs a padding token, and that no padding does not. + */ + @Test + void testPaddingRequiresAPaddingToken(@TempDir final Path dir) throws Exception { + final Path vocabulary = dir.resolve("vocab-nopad.txt"); + Files.write(vocabulary, List.of("unused0", "unused1", "[UNK]", "[SEP]", "hello", "world", + "unused2", "[CLS]")); + for (final PaddingStrategy padding : List.of(PaddingStrategy.LONGEST, + PaddingStrategy.MAX_LENGTH)) { + assertThrows(IllegalArgumentException.class, () -> new SentenceVectorsDL(model(dir, TINY_VECTORS), + vocabulary.toFile(), true, Pooling.MEAN, false, 8, padding, new InferenceOptions()), + padding.name()); + } + try (SentenceVectorsDL exact = new SentenceVectorsDL(model(dir, TINY_VECTORS), vocabulary.toFile(), true, + Pooling.MEAN, false, 8, PaddingStrategy.EXACT_LENGTH, new InferenceOptions())) { + assertArrayEquals(HELLO_MEAN, exact.embed("hello"), DELTA); + } + assertEquals("padding must not be null", assertThrows(IllegalArgumentException.class, + () -> meanVectors(dir, 8, null)).getMessage()); + } + /** * Asserts the batch contract edges: an empty input yields an empty batch, and a * {@code null} element is rejected rather than failing later inside the session. diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLPaddingEdgesTest.java b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLPaddingEdgesTest.java new file mode 100644 index 000000000..e54068e0f --- /dev/null +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLPaddingEdgesTest.java @@ -0,0 +1,337 @@ +/* + * 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.vectors; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +import opennlp.dl.InferenceOptions; +import opennlp.dl.Tokens; + +import static opennlp.dl.vectors.TinyVectorModels.TINY_MASKED; +import static opennlp.dl.vectors.TinyVectorModels.TINY_POOLED; +import static opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS; +import static opennlp.dl.vectors.TinyVectorModels.gpuOrAbort; +import static opennlp.dl.vectors.TinyVectorModels.model; +import static opennlp.dl.vectors.TinyVectorModels.scale; +import static opennlp.dl.vectors.TinyVectorModels.vocab; +import static opennlp.dl.vectors.TinyVectorModels.vocabPad6; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Edge cases of the padding strategies of {@link SentenceVectorsDL#embedAll(List)}: the limit of + * token positions per inference under padding, other vocabularies, pooling and normalization of + * padded rows, a model that ignores the mask, and a GPU. + * + * <p>With the test vocabulary "hello" encodes to 3 tokens and "hello world" to 4.</p> + */ +class SentenceVectorsDLPaddingEdgesTest { + + private static final int LIMIT = SentenceVectorsDL.MAX_BATCH_TOKEN_POSITIONS; + + private static final float DELTA = 1e-5f; + + private static final float[] UNIT = scale(1 / (float) Math.sqrt(5.25)); + + private static File write(final Path dir, final String name, final List<String> tokens) + throws IOException { + final Path file = dir.resolve(name); + Files.write(file, tokens); + return file.toFile(); + } + + private static SentenceVectorsDL embedder(final File model, final File vocabulary, + final Pooling pooling, final boolean normalize, final int maxLength, + final PaddingStrategy padding) throws Exception { + return new SentenceVectorsDL(model, vocabulary, true, pooling, normalize, maxLength, padding, + new InferenceOptions()); + } + + private static Tokens[] tokens(final int length, final int count) { + return Collections.nCopies(count, TinyVectorModels.tokens(length)).toArray(new Tokens[0]); + } + + private static List<Integer> sizes(final List<List<Integer>> batches) { + return batches.stream().map(List::size).toList(); + } + + // The plan tests do not run a text, so one embedder per strategy is enough. + private static SentenceVectorsDL planner(final Path dir, final PaddingStrategy padding, + final int maxLength) throws Exception { + return embedder(model(dir, TINY_VECTORS), vocab(dir), Pooling.MEAN, false, maxLength, + padding); + } + + /** + * LONGEST fills an inference to exactly the limit, splits at one row more, and runs a row wider + * than the limit on its own. + */ + @Test + void testLongestAtTheLimitAndOverIt(@TempDir final Path dir) throws Exception { + try (SentenceVectorsDL longest = planner(dir, PaddingStrategy.LONGEST, 8)) { + assertEquals(List.of(LIMIT / 4), sizes(longest.batches(tokens(4, LIMIT / 4)))); + assertEquals(List.of(LIMIT / 4, 1), sizes(longest.batches(tokens(4, LIMIT / 4 + 1)))); + assertEquals(List.of(LIMIT / 2, 1), sizes(longest.batches(tokens(2, LIMIT / 2 + 1)))); + assertEquals(List.of(List.of(0)), longest.batches(tokens(LIMIT + 1, 1))); + final Tokens[] mixed = {tokens(LIMIT + 1, 1)[0], tokens(3, 1)[0], tokens(LIMIT + 1, 1)[0]}; + assertEquals(List.of(List.of(1), List.of(0), List.of(2)), longest.batches(mixed)); + } + } + + /** MAX_LENGTH with a length above the limit runs one row per inference. */ + @Test + void testMaxLengthAboveTheLimitRunsOneRowEach(@TempDir final Path dir) throws Exception { + try (SentenceVectorsDL fixed = planner(dir, PaddingStrategy.MAX_LENGTH, LIMIT + 5)) { + assertEquals(List.of(1, 1, 1), sizes(fixed.batches(tokens(3, 3)))); + } + } + + /** MAX_LENGTH with lengths that divide the limit evenly, or equal it. */ + @ParameterizedTest + @MethodSource("maxLengthPlans") + void testMaxLengthThatDividesTheLimit(final int maxLength, final int count, + final List<Integer> expected, @TempDir final Path dir) throws Exception { + try (SentenceVectorsDL fixed = planner(dir, PaddingStrategy.MAX_LENGTH, maxLength)) { + assertEquals(expected, sizes(fixed.batches(tokens(3, count)))); + } + } + + static Stream<Arguments> maxLengthPlans() { + return Stream.of( + Arguments.of(8, 2048, List.of(2048)), + Arguments.of(8, 2049, List.of(2048, 1)), + Arguments.of(8, 4097, List.of(2048, 2048, 1)), + Arguments.of(LIMIT, 2, List.of(1, 1)), + Arguments.of(LIMIT / 2, 3, List.of(2, 1))); + } + + /** + * An input as long as a MAX_LENGTH wider than the limit still embeds, in call order, next to + * short inputs. + */ + @Test + void testMaxLengthAboveTheLimitEmbeds(@TempDir final Path dir) throws Exception { + try (SentenceVectorsDL fixed = embedder(model(dir, TINY_MASKED), vocabPad6(dir), + Pooling.MEAN, false, LIMIT + 5, PaddingStrategy.MAX_LENGTH)) { + final float[][] batch = fixed.embedAll(List.of("hello world", "hello", "world")); + assertArrayEquals(scale(19), batch[0], DELTA); + assertArrayEquals(scale(14), batch[1], DELTA); + assertArrayEquals(scale(15), batch[2], DELTA); + } + } + + /** Alternating lengths with duplicates come back in input order after LONGEST sorts them. */ + @Test + void testInterleavedLengthsKeepInputOrder(@TempDir final Path dir) throws Exception { + try (SentenceVectorsDL longest = embedder(model(dir, TINY_MASKED), vocabPad6(dir), + Pooling.MEAN, false, 8, PaddingStrategy.LONGEST)) { + final List<String> texts = new ArrayList<>(); + for (int i = 0; i < LIMIT / 4 + 1; i++) { + texts.add("hello world"); + texts.add("hello"); + texts.add("hello world"); + texts.add("world"); + } + final float[][] batch = longest.embedAll(texts); + assertEquals(texts.size(), batch.length); + for (int i = 0; i < batch.length; i++) { + final String text = texts.get(i); + final float sum = "hello world".equals(text) ? 19 : "hello".equals(text) ? 14 : 15; + assertArrayEquals(scale(sum), batch[i], DELTA, "row " + i); + } + } + } + + /** + * A RoBERTa vocabulary with {@code <pad>} pads with that token: {@code tiny-masked.onnx} sums + * the ids times the mask, so padded rows give the sum of their own ids. + */ + @ParameterizedTest + @EnumSource(value = PaddingStrategy.class, names = {"LONGEST", "MAX_LENGTH"}) + void testRobertaVocabularyPads(final PaddingStrategy padding, @TempDir final Path dir) + throws Exception { + // <s>=0 <pad>=1 </s>=2 <unk>=3 hello=4 world=5 + final File roberta = write(dir, "roberta.txt", + List.of("<s>", "<pad>", "</s>", "<unk>", "hello", "world")); + try (SentenceVectorsDL vectors = embedder(model(dir, TINY_MASKED), roberta, + Pooling.MEAN, false, 8, padding)) { + final float[][] batch = vectors.embedAll(List.of("hello world", "hello")); + assertArrayEquals(scale(11), batch[0], DELTA); + assertArrayEquals(scale(6), batch[1], DELTA); + } + } + + /** A padding id of 0, the usual case, gives the same masked sums as the id 6 fixture. */ + @ParameterizedTest + @EnumSource(value = PaddingStrategy.class, names = {"LONGEST", "MAX_LENGTH"}) + void testPaddingIdZero(final PaddingStrategy padding, @TempDir final Path dir) + throws Exception { + try (SentenceVectorsDL vectors = embedder(model(dir, TINY_MASKED), vocab(dir), + Pooling.MEAN, false, 8, padding)) { + final float[][] batch = vectors.embedAll(List.of("hello world", "hello", "world", "")); + assertArrayEquals(scale(19), batch[0], DELTA); + assertArrayEquals(scale(14), batch[1], DELTA); + assertArrayEquals(scale(15), batch[2], DELTA); + assertArrayEquals(scale(10), batch[3], DELTA); + } + } + + static Stream<Arguments> poolingCases() { + final List<Arguments> cases = new ArrayList<>(); + for (final PaddingStrategy padding : List.of(PaddingStrategy.LONGEST, + PaddingStrategy.MAX_LENGTH)) { + cases.add(Arguments.of(padding, Pooling.CLS, false, scale(7), scale(7))); + cases.add(Arguments.of(padding, Pooling.CLS, true, UNIT, UNIT)); + cases.add(Arguments.of(padding, Pooling.MEAN, true, UNIT, UNIT)); + } + return cases.stream(); + } + + /** + * CLS pooling reads the first position, which padding never moves, and unit length scaling + * applies per row, for padded rows too. + */ + @ParameterizedTest + @MethodSource("poolingCases") + void testClsAndNormalizeUnderPadding(final PaddingStrategy padding, final Pooling pooling, + final boolean normalize, final float[] helloWorld, final float[] hello, + @TempDir final Path dir) throws Exception { + try (SentenceVectorsDL vectors = embedder(model(dir, TINY_VECTORS), vocabPad6(dir), + pooling, normalize, 8, padding)) { + final float[][] batch = vectors.embedAll(List.of("hello world", "hello", "hello world")); + assertArrayEquals(helloWorld, batch[0], DELTA); + assertArrayEquals(hello, batch[1], DELTA); + assertArrayEquals(helloWorld, batch[2], DELTA); + } + } + + /** + * The pooled-output model ignores the mask: a padded row also sums the padding id, so a batch + * differs from the single-input result under LONGEST, and every call pads to the maximum length + * under MAX_LENGTH, single calls included. This pins the documented caveat. + */ + @Test + void testPooledOutputModelSumsThePadding(@TempDir final Path dir) throws Exception { + final File pooled = model(dir, TINY_POOLED); + final File vocabulary = vocabPad6(dir); + try (SentenceVectorsDL longest = embedder(pooled, vocabulary, Pooling.MEAN, false, 8, + PaddingStrategy.LONGEST); + SentenceVectorsDL fixed = embedder(pooled, vocabulary, Pooling.MEAN, false, 8, + PaddingStrategy.MAX_LENGTH)) { + final float[][] batch = longest.embedAll(List.of("hello world", "hello")); + assertArrayEquals(scale(19), batch[0], DELTA); + assertArrayEquals(scale(14 + 6), batch[1], DELTA); + assertFalse(java.util.Arrays.equals(longest.embed("hello"), batch[1])); + assertArrayEquals(scale(14), longest.embed("hello"), DELTA); + assertArrayEquals(scale(14 + 5 * 6), fixed.embed("hello"), DELTA); + assertArrayEquals(scale(19 + 4 * 6), fixed.embed("hello world"), DELTA); + } + } + + /** + * A single {@code embed} under MAX_LENGTH pads to the maximum length: a model that ignores the + * mask shows the padding ids, a model that reads it gives the unpadded sum. + */ + @Test + void testSingleEmbedUnderMaxLengthIsPadded(@TempDir final Path dir) throws Exception { + try (SentenceVectorsDL ignoring = embedder(model(dir, TINY_POOLED), vocabPad6(dir), + Pooling.MEAN, false, 6, PaddingStrategy.MAX_LENGTH); + SentenceVectorsDL masked = embedder(model(dir, TINY_MASKED), vocabPad6(dir), + Pooling.MEAN, false, 6, PaddingStrategy.MAX_LENGTH)) { + assertArrayEquals(scale(14 + 3 * 6), ignoring.embed("hello"), DELTA); + assertArrayEquals(scale(14), masked.embed("hello"), DELTA); + assertEquals(3, masked.dimension()); + } + } + + static Stream<Arguments> boundaryPlans() { + final List<Arguments> cases = new ArrayList<>(); + for (final PaddingStrategy padding : PaddingStrategy.values()) { + cases.add(Arguments.of(padding, 4, LIMIT / 4, List.of(LIMIT / 4))); + cases.add(Arguments.of(padding, 4, LIMIT / 4 + 1, List.of(LIMIT / 4, 1))); + cases.add(Arguments.of(padding, 2, LIMIT / 2, List.of(LIMIT / 2))); + cases.add(Arguments.of(padding, 2, 0, List.of())); + } + return cases.stream(); + } + + /** + * The row count boundaries of GH-1391 hold under every strategy, with the maximum length set + * to the width of the rows so that MAX_LENGTH plans the same inferences. + */ + @ParameterizedTest + @MethodSource("boundaryPlans") + void testRowCountBoundariesUnderEveryStrategy(final PaddingStrategy padding, final int width, + final int count, final List<Integer> expected, @TempDir final Path dir) throws Exception { + try (SentenceVectorsDL vectors = planner(dir, padding, width)) { + assertEquals(expected, sizes(vectors.batches(tokens(width, count)))); + } + } + + /** A row as wide as the limit runs alone under every strategy, as does a wider one. */ + @ParameterizedTest + @EnumSource(PaddingStrategy.class) + void testRowAtTheLimitRunsAlone(final PaddingStrategy padding, @TempDir final Path dir) + throws Exception { + try (SentenceVectorsDL vectors = planner(dir, padding, LIMIT)) { + assertEquals(List.of(1, 1, 1), sizes(vectors.batches(tokens(LIMIT, 3)))); + assertEquals(List.of(1, 1), sizes(vectors.batches(tokens(LIMIT + 1, 2)))); + } + } + + /** + * LONGEST and MAX_LENGTH give the vectors of the CPU session on a CUDA device 0 for a call that + * splits; skipped where there is no CUDA device. + */ + @ParameterizedTest + @EnumSource(value = PaddingStrategy.class, names = {"LONGEST", "MAX_LENGTH"}) + void testGpuMatchesTheCpuUnderPadding(final PaddingStrategy padding, @TempDir final Path dir) + throws Exception { + final File model = model(dir, TINY_MASKED); + final File vocabulary = vocabPad6(dir); + final List<String> texts = new ArrayList<>(Collections.nCopies(LIMIT / 4 + 1, "hello world")); + texts.add("hello"); + texts.add("world"); + try (SentenceVectorsDL gpu = gpuOrAbort(model, vocabulary, Pooling.MEAN, false, 8, padding); + SentenceVectorsDL cpu = embedder(model, vocabulary, Pooling.MEAN, false, 8, padding)) { + final float[][] expected = cpu.embedAll(texts); + final float[][] actual = gpu.embedAll(texts); + assertEquals(expected.length, actual.length); + for (int i = 0; i < expected.length; i++) { + assertArrayEquals(expected[i], actual[i], DELTA, "row " + i); + } + assertTrue(expected.length > LIMIT / 4); + } + } +} diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java index 9cb6bcc28..8be2defb4 100644 --- a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java @@ -30,6 +30,7 @@ import ai.onnxruntime.OrtException; import ai.onnxruntime.OrtProvider; import opennlp.dl.InferenceOptions; +import opennlp.dl.Tokens; import static org.junit.jupiter.api.Assumptions.abort; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -48,6 +49,9 @@ public final class TinyVectorModels { /** The model with an additional {@code sentence_embedding} output. */ public static final String TINY_POOLED = "tiny-pooled.onnx"; + /** The model whose output depends on the attention mask, see {@code gen_tiny_masked_model.py}. */ + public static final String TINY_MASKED = "tiny-masked.onnx"; + private static final String VECTORS_RESOURCES = "/opennlp/dl/vectors/"; private TinyVectorModels() { @@ -101,6 +105,30 @@ public final class TinyVectorModels { return file.toFile(); } + /** + * Writes the vocabulary of {@link #vocab(Path)} with {@code [PAD]} at id 6 instead of 0, so a + * padded position is visible as that id. + * + * @param dir The directory to write into. + * @return The vocabulary file. + * @throws IOException Thrown if the vocabulary cannot be written. + */ + public static File vocabPad6(final Path dir) throws IOException { + final Path file = dir.resolve("vocab-pad6.txt"); + Files.write(file, List.of("unused0", "unused1", "[UNK]", "[SEP]", "hello", "world", + "[PAD]", "[CLS]")); + return file.toFile(); + } + + /** + * {@return an encoding of {@code length} tokens; only its length matters for batching} + * + * @param length The number of tokens. + */ + public static Tokens tokens(final int length) { + return new Tokens(new String[length], new long[length], new long[length], new long[length]); + } + /** * {@return {@code W} times {@code factor}, the expected vector for a mean or summed id} * @@ -122,6 +150,23 @@ public final class TinyVectorModels { return options; } + /** + * Opens a session on CUDA device {@code 0} with the default maximum length and padding, or + * aborts the calling test where there is no usable CUDA device. + * + * @param model The model file. + * @param vocab The vocabulary file. + * @param pooling The pooling. + * @param normalize {@code true} to scale every vector to unit length. + * @return The session on the GPU. + * @throws IOException Thrown if the model or vocabulary cannot be read. + */ + public static SentenceVectorsDL gpuOrAbort(final File model, final File vocab, + final Pooling pooling, final boolean normalize) throws IOException { + return gpuOrAbort(model, vocab, pooling, normalize, SentenceVectorsDL.DEFAULT_MAX_LENGTH, + SentenceVectorsDL.DEFAULT_PADDING); + } + /** * Opens a session on CUDA device {@code 0}, or aborts the calling test where the ONNX Runtime * on the classpath has no CUDA execution provider or the device is not usable. @@ -130,16 +175,19 @@ public final class TinyVectorModels { * @param vocab The vocabulary file. * @param pooling The pooling. * @param normalize {@code true} to scale every vector to unit length. + * @param maxLength The maximum number of tokens per input. + * @param padding The padding strategy. * @return The session on the GPU. * @throws IOException Thrown if the model or vocabulary cannot be read. */ public static SentenceVectorsDL gpuOrAbort(final File model, final File vocab, - final Pooling pooling, final boolean normalize) throws IOException { + final Pooling pooling, final boolean normalize, final int maxLength, + final PaddingStrategy padding) throws IOException { assumeTrue(OrtEnvironment.getAvailableProviders().contains(OrtProvider.CUDA), "the ONNX Runtime on the classpath has no CUDA execution provider"); try { - return new SentenceVectorsDL(model, vocab, true, pooling, normalize, - SentenceVectorsDL.DEFAULT_MAX_LENGTH, gpu(0)); + return new SentenceVectorsDL(model, vocab, true, pooling, normalize, maxLength, padding, + gpu(0)); } catch (OrtException e) { return abort("no usable CUDA device 0: " + e.getMessage()); } diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/gen_tiny_masked_model.py b/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/gen_tiny_masked_model.py new file mode 100644 index 000000000..9f0e726f9 --- /dev/null +++ b/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/gen_tiny_masked_model.py @@ -0,0 +1,102 @@ +# 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. + +# Generates tiny-masked.onnx, the model behind the SentenceVectorsDL padding tests that +# depend on attention_mask. +# +# The graph declares the three BERT-style inputs and two outputs, in this order: +# token_embeddings[b][t] = float(input_ids[b][t] * attention_mask[b][t]) * W +# shape [batch, tokens, 3] +# sentence_embedding[b] = sum over t of token_embeddings[b][t] shape [batch, 3] +# with W = [[0.5, -1.0, 2.0]], so the pooled output of an input is the sum of the vocabulary ids +# at positions the mask covers, times W. A padded position with a mask of 0 adds 0; one +# with a mask of 1 adds the padding id, which is how the tests distinguish them. +# +# Uses only the Python standard library. Regenerate with: python3 gen_tiny_masked_model.py + +import array +import struct + + +def varint(n): + out = bytearray() + while True: + b = n & 0x7F + n >>= 7 + if n: + out.append(b | 0x80) + else: + out.append(b) + return bytes(out) + + +def key(field, wire): + return varint((field << 3) | wire) + + +def num(field, n): + return key(field, 0) + varint(n) + + +def raw(field, b): + if isinstance(b, str): + b = b.encode() + return key(field, 2) + varint(len(b)) + b + + +def tensor(name, dims, dtype, data): + return b"".join(num(1, d) for d in dims) + num(2, dtype) + raw(8, name) + raw(9, data) + + +def node(op, inputs, outputs, attrs=()): + return (b"".join(raw(1, i) for i in inputs) + b"".join(raw(2, o) for o in outputs) + + raw(4, op) + b"".join(raw(5, a) for a in attrs)) + + +def attr_int(name, value): + return raw(1, name) + num(3, value) + num(20, 2) + + +def value_info(name, elem, dims): + shape = b"".join(raw(1, num(1, d) if isinstance(d, int) else raw(2, d)) for d in dims) + return raw(1, name) + raw(2, raw(1, num(1, elem) + raw(2, shape))) + + +FLOAT, INT64 = 1, 7 +nodes = [ + node("Cast", ["input_ids"], ["ids_float"], [attr_int("to", FLOAT)]), + node("Cast", ["attention_mask"], ["mask_float"], [attr_int("to", FLOAT)]), + node("Mul", ["ids_float", "mask_float"], ["masked_ids"]), + node("Unsqueeze", ["masked_ids", "axes2"], ["ids_3d"]), + node("MatMul", ["ids_3d", "w"], ["token_embeddings"]), + node("ReduceSum", ["token_embeddings", "axes1"], ["sentence_embedding"], + [attr_int("keepdims", 0)]), +] +initializers = [ + tensor("axes2", [1], INT64, array.array("q", [2]).tobytes()), + tensor("axes1", [1], INT64, array.array("q", [1]).tobytes()), + tensor("w", [1, 3], FLOAT, struct.pack("<3f", 0.5, -1.0, 2.0)), +] +inputs = [value_info(n, INT64, ["batch", "tokens"]) + for n in ("input_ids", "attention_mask", "token_type_ids")] +outputs = [value_info("token_embeddings", FLOAT, ["batch", "tokens", 3]), + value_info("sentence_embedding", FLOAT, ["batch", 3])] +graph = (b"".join(raw(1, n) for n in nodes) + raw(2, "tiny-masked") + + b"".join(raw(5, t) for t in initializers) + + b"".join(raw(11, i) for i in inputs) + b"".join(raw(12, o) for o in outputs)) +model = num(1, 8) + raw(8, raw(1, "") + num(2, 13)) + raw(7, graph) +with open("tiny-masked.onnx", "wb") as f: + f.write(model) +print("wrote tiny-masked.onnx,", len(model), "bytes") diff --git a/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/tiny-masked.onnx b/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/tiny-masked.onnx new file mode 100644 index 000000000..3f17cf264 Binary files /dev/null and b/opennlp-core/opennlp-ml/opennlp-dl/src/test/resources/opennlp/dl/vectors/tiny-masked.onnx differ diff --git a/opennlp-docs/src/docbkx/machine-learning.xml b/opennlp-docs/src/docbkx/machine-learning.xml index c1810d0ce..00831b676 100644 --- a/opennlp-docs/src/docbkx/machine-learning.xml +++ b/opennlp-docs/src/docbkx/machine-learning.xml @@ -173,10 +173,17 @@ try (TextEmbedder embedder = providers.select(spec).create(spec)) { <code>lowerCase=false</code>, <code>pooling=mean</code> (default) or <code>pooling=cls</code>, <code>normalize=true</code> (default) or <code>normalize=false</code>, and <code>maxLength</code>, the maximum number of tokens - per input (512 by default); longer input is truncated. The defaults match - sentence-transformers models such as all-MiniLM-L6-v2. A model with a - <code>sentence_embedding</code> output is used as it is, without pooling. A spec with - another option is not supported. The key + per input (512 by default); longer input is truncated. The option <code>padding</code> + sets how <code>embedAll</code> batches texts of different lengths: + <code>exact_length</code> (default) does not pad and runs one inference per tokenized + length, <code>longest</code> pads each batch to its longest text, and + <code>max_length</code> pads every text to <code>maxLength</code>. The vectors match up + to floating-point rounding either way, provided the model honors the attention mask; a + <code>sentence_embedding</code> output that ignores the mask changes with padding. + Padding needs a <code>[PAD]</code> token in the vocabulary, or <code><pad></code> + for a RoBERTa vocabulary. The defaults match sentence-transformers models such as + all-MiniLM-L6-v2. A model with a <code>sentence_embedding</code> output is used as it + is, without pooling. A spec with another option is not supported. The key <code>opennlp.provider.opennlp.tools.embeddings.TextEmbedderProvider</code> selects a provider by name. Excluding <code>opennlp-dl</code> leaves the API without a provider until another one is installed. Existing <code>SentenceVectorsDL</code> constructors remain diff --git a/rat-excludes b/rat-excludes index 09d8e7f5d..0493dfdcc 100644 --- a/rat-excludes +++ b/rat-excludes @@ -71,6 +71,7 @@ src/test/resources/opennlp/tools/tokenize/uax29/WordBreakTest.txt # opennlp-dl tiny ONNX test models (project-generated binaries, see the gen_tiny_*_model.py scripts next to them) src/test/resources/opennlp/dl/vectors/tiny-vectors.onnx src/test/resources/opennlp/dl/vectors/tiny-pooled.onnx +src/test/resources/opennlp/dl/vectors/tiny-masked.onnx # test fixtures and expected outputs (data files, no Apache header possible) src/test/resources/opennlp/tools/util/wordvector/glove-example-*.txt
