You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opennlp.apache.org by rz...@apache.org on 2026/03/19 09:45:12 UTC
(opennlp) 01/01: OPENNLP-1808: Add SVM-based document categorization via zlibsvm
This is an automated email from the ASF dual-hosted git repository.
rzo1 pushed a commit to branch OPENNLP-1808
in repository https://gitbox.apache.org/repos/asf/opennlp.git
commit acfa9a25273f0d23b931098bfd92e2ecf5e2634a
Author: Richard Zowalla <rz...@apache.org>
AuthorDate: Thu Mar 19 10:44:58 2026 +0100
OPENNLP-1808: Add SVM-based document categorization via zlibsvm
Introduces opennlp-ml-libsvm, a new ML module providing SVM-based text
classification through the zlibsvm library. The module implements the
DocumentCategorizer interface and includes:
- Configurable term weighting (binary, TF, TF-IDF, log-normalized TF)
- Feature selection (information gain, chi-square, TF, DF)
- Feature scaling with configurable range
- Full SVM parameter control (kernel, cost, gamma, etc.)
- Model serialization/deserialization
- CLI tools (DoccatSVM, DoccatSVMTrainer, DoccatSVMEvaluator)
- 86 unit tests across 8 test classes
- Documentation in doccat.xml, project-structure.xml, and README
---
README.md | 3 +-
opennlp-core/opennlp-cli/pom.xml | 5 +
.../src/main/java/opennlp/tools/cmdline/CLI.java | 8 +
.../cmdline/doccat/DoccatSVMEvaluatorTool.java | 117 +++++
.../tools/cmdline/doccat/DoccatSVMTool.java | 107 +++++
.../tools/cmdline/doccat/DoccatSVMTrainerTool.java | 109 +++++
opennlp-core/opennlp-ml/opennlp-ml-libsvm/pom.xml | 67 +++
.../ml/libsvm/doccat/DocumentCategorizerSVM.java | 410 ++++++++++++++++
.../tools/ml/libsvm/doccat/FeatureSelection.java | 234 +++++++++
.../ml/libsvm/doccat/FeatureSelectionStrategy.java | 77 +++
.../ml/libsvm/doccat/SvmDoccatConfiguration.java | 217 +++++++++
.../tools/ml/libsvm/doccat/SvmDoccatModel.java | 199 ++++++++
.../ml/libsvm/doccat/TermWeightingStrategy.java | 88 ++++
.../ml/libsvm/doccat/TrainingSvmClassLabel.java | 69 +++
.../ml/libsvm/doccat/TrainingSvmDocument.java | 74 +++
.../doccat/DocumentCategorizerSVMEdgeCaseTest.java | 263 ++++++++++
.../libsvm/doccat/DocumentCategorizerSVMTest.java | 527 +++++++++++++++++++++
.../ml/libsvm/doccat/FeatureSelectionTest.java | 171 +++++++
.../libsvm/doccat/SvmDoccatConfigurationTest.java | 152 ++++++
.../tools/ml/libsvm/doccat/SvmDoccatModelTest.java | 163 +++++++
.../libsvm/doccat/TermWeightingStrategyTest.java | 53 +++
.../libsvm/doccat/TrainingSvmClassLabelTest.java | 59 +++
.../ml/libsvm/doccat/TrainingSvmDocumentTest.java | 100 ++++
opennlp-core/opennlp-ml/pom.xml | 1 +
opennlp-docs/src/docbkx/doccat.xml | 115 ++++-
opennlp-docs/src/docbkx/project-structure.xml | 10 +
opennlp-tools/pom.xml | 5 +
pom.xml | 7 +
28 files changed, 3407 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 3c5d68e0..1564eb7a 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ The goal of the OpenNLP project is to be a mature toolkit for the above mentione
An additional goal is to provide a large number of pre-built models for a variety of languages, as
well as the annotated text resources that those models are derived from.
-Presently, OpenNLP includes common classifiers such as Maximum Entropy, Perceptron and Naive Bayes.
+Presently, OpenNLP includes common classifiers such as Maximum Entropy, Perceptron, Naive Bayes and Support Vector Machines (SVM).
OpenNLP can be used both programmatically through its Java API or from a terminal through its CLI.
OpenNLP API can be easily plugged into distributed streaming data pipelines like Apache Flink, Apache NiFi, Apache Spark.
@@ -74,6 +74,7 @@ Currently, the library has different modules:
* `opennlp-ml-maxent` : Maximum Entropy (MaxEnt) machine learning implementation.
* `opennlp-ml-bayes` : Naive Bayes machine learning implementation.
* `opennlp-ml-perceptron` : Perceptron-based machine learning implementation.
+* `opennlp-ml-libsvm` : Support Vector Machine (SVM) based text classification via [zlibsvm](https://github.com/rzo1/zlibsvm).
* `opennlp-dl` : Apache OpenNLP adapter for [ONNX](https://onnx.ai) models using the `onnxruntime` dependency.
* `opennlp-dl-gpu` : Replaces `onnxruntime` with the `onnxruntime_gpu` dependency to support GPU acceleration.
* `opennlp-model-resolver` : Classes for discovering and loading Apache OpenNLP models from the classpath.
diff --git a/opennlp-core/opennlp-cli/pom.xml b/opennlp-core/opennlp-cli/pom.xml
index bab98354..d24a92f9 100644
--- a/opennlp-core/opennlp-cli/pom.xml
+++ b/opennlp-core/opennlp-cli/pom.xml
@@ -51,6 +51,11 @@
<scope>runtime</scope>
</dependency>
+ <dependency>
+ <artifactId>opennlp-ml-libsvm</artifactId>
+ <groupId>${project.groupId}</groupId>
+ </dependency>
+
<!-- External dependencies -->
<dependency>
<groupId>org.slf4j</groupId>
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
index 6c041375..7904561c 100644
--- a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/CLI.java
@@ -37,6 +37,9 @@ import opennlp.tools.cmdline.dictionary.DictionaryBuilderTool;
import opennlp.tools.cmdline.doccat.DoccatConverterTool;
import opennlp.tools.cmdline.doccat.DoccatCrossValidatorTool;
import opennlp.tools.cmdline.doccat.DoccatEvaluatorTool;
+import opennlp.tools.cmdline.doccat.DoccatSVMEvaluatorTool;
+import opennlp.tools.cmdline.doccat.DoccatSVMTool;
+import opennlp.tools.cmdline.doccat.DoccatSVMTrainerTool;
import opennlp.tools.cmdline.doccat.DoccatTool;
import opennlp.tools.cmdline.doccat.DoccatTrainerTool;
import opennlp.tools.cmdline.entitylinker.EntityLinkerTool;
@@ -100,6 +103,11 @@ public final class CLI {
tools.add(new DoccatCrossValidatorTool());
tools.add(new DoccatConverterTool());
+ // Document Categorizer (SVM)
+ tools.add(new DoccatSVMTool());
+ tools.add(new DoccatSVMTrainerTool());
+ tools.add(new DoccatSVMEvaluatorTool());
+
// Language Detector
tools.add(new LanguageDetectorTool());
tools.add(new LanguageDetectorTrainerTool());
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMEvaluatorTool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMEvaluatorTool.java
new file mode 100644
index 00000000..bb89ff67
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMEvaluatorTool.java
@@ -0,0 +1,117 @@
+/*
+ * 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.cmdline.doccat;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.cmdline.AbstractEvaluatorTool;
+import opennlp.tools.cmdline.PerformanceMonitor;
+import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.cmdline.doccat.DoccatSVMEvaluatorTool.EvalToolParams;
+import opennlp.tools.cmdline.params.EvaluatorParams;
+import opennlp.tools.doccat.BagOfWordsFeatureGenerator;
+import opennlp.tools.doccat.DoccatEvaluationMonitor;
+import opennlp.tools.doccat.DocumentCategorizerEvaluator;
+import opennlp.tools.doccat.DocumentSample;
+import opennlp.tools.ml.libsvm.doccat.SvmDoccatModel;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.eval.EvaluationMonitor;
+
+/**
+ * CLI tool for evaluating an SVM-based document categorization model.
+ * <p>
+ * Usage: {@code opennlp DoccatSVMEvaluator -model model -data testData}
+ */
+public final class DoccatSVMEvaluatorTool extends
+ AbstractEvaluatorTool<DocumentSample, EvalToolParams> {
+
+ interface EvalToolParams extends EvaluatorParams {
+ }
+
+ private static final Logger logger = LoggerFactory.getLogger(DoccatSVMEvaluatorTool.class);
+
+ public DoccatSVMEvaluatorTool() {
+ super(DocumentSample.class, EvalToolParams.class);
+ }
+
+ @Override
+ public String getShortDescription() {
+ return "Measures the performance of the SVM Doccat model with the reference data";
+ }
+
+ @Override
+ public void run(String format, String[] args) {
+ super.run(format, args);
+
+ SvmDoccatModel model;
+ try (FileInputStream in = new FileInputStream(params.getModel())) {
+ model = SvmDoccatModel.deserialize(in);
+ } catch (IOException | ClassNotFoundException e) {
+ throw new TerminateToolException(-1,
+ "Failed to load SVM Doccat model: " + e.getMessage(), e);
+ }
+
+ List<EvaluationMonitor<DocumentSample>> listeners = new LinkedList<>();
+ if (params.getMisclassified()) {
+ listeners.add(new DoccatEvaluationErrorListener());
+ }
+
+ opennlp.tools.ml.libsvm.doccat.DocumentCategorizerSVM categorizer =
+ new opennlp.tools.ml.libsvm.doccat.DocumentCategorizerSVM(
+ model, new BagOfWordsFeatureGenerator());
+
+ DocumentCategorizerEvaluator evaluator = new DocumentCategorizerEvaluator(
+ categorizer, listeners.toArray(new DoccatEvaluationMonitor[0]));
+
+ final PerformanceMonitor monitor = new PerformanceMonitor("doc");
+
+ try (ObjectStream<DocumentSample> measuredSampleStream = new ObjectStream<>() {
+ @Override
+ public DocumentSample read() throws IOException {
+ monitor.incrementCounter();
+ return sampleStream.read();
+ }
+
+ @Override
+ public void reset() throws IOException {
+ sampleStream.reset();
+ }
+
+ @Override
+ public void close() throws IOException {
+ sampleStream.close();
+ }
+ }) {
+ monitor.startAndPrintThroughput();
+ evaluator.evaluate(measuredSampleStream);
+ } catch (IOException e) {
+ throw new TerminateToolException(-1,
+ "IO error while reading test data: " + e.getMessage(), e);
+ }
+
+ monitor.stopAndPrintFinalResult();
+
+ logger.info(evaluator.toString());
+ }
+}
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMTool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMTool.java
new file mode 100644
index 00000000..711d0ca4
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMTool.java
@@ -0,0 +1,107 @@
+/*
+ * 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.cmdline.doccat;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.cmdline.BasicCmdLineTool;
+import opennlp.tools.cmdline.CLI;
+import opennlp.tools.cmdline.CmdLineUtil;
+import opennlp.tools.cmdline.PerformanceMonitor;
+import opennlp.tools.cmdline.SystemInputStreamFactory;
+import opennlp.tools.doccat.BagOfWordsFeatureGenerator;
+import opennlp.tools.doccat.DocumentSample;
+import opennlp.tools.ml.libsvm.doccat.SvmDoccatModel;
+import opennlp.tools.tokenize.WhitespaceTokenizer;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.ParagraphStream;
+import opennlp.tools.util.PlainTextByLineStream;
+
+/**
+ * CLI tool for classifying documents using an SVM-based document categorization model.
+ * <p>
+ * Usage: {@code opennlp DoccatSVM model < documents}
+ */
+public class DoccatSVMTool extends BasicCmdLineTool {
+
+ private static final Logger logger = LoggerFactory.getLogger(DoccatSVMTool.class);
+
+ @Override
+ public String getShortDescription() {
+ return "SVM-based document categorizer";
+ }
+
+ @Override
+ public String getHelp() {
+ return "Usage: " + CLI.CMD + " " + getName() + " model < documents";
+ }
+
+ @Override
+ public void run(String[] args) {
+
+ if (0 == args.length) {
+ logger.info(getHelp());
+ } else {
+
+ File modelFile = new File(args[0]);
+ CmdLineUtil.checkInputFile("SVM Doccat model", modelFile);
+
+ SvmDoccatModel model;
+ try (FileInputStream in = new FileInputStream(modelFile)) {
+ model = SvmDoccatModel.deserialize(in);
+ } catch (IOException | ClassNotFoundException e) {
+ throw new RuntimeException("Failed to load SVM Doccat model: " + e.getMessage(), e);
+ }
+
+ opennlp.tools.ml.libsvm.doccat.DocumentCategorizerSVM categorizer =
+ new opennlp.tools.ml.libsvm.doccat.DocumentCategorizerSVM(
+ model, new BagOfWordsFeatureGenerator());
+
+ ObjectStream<String> documentStream;
+
+ PerformanceMonitor perfMon = new PerformanceMonitor("doc");
+ perfMon.start();
+
+ try {
+ documentStream = new ParagraphStream(new PlainTextByLineStream(
+ new SystemInputStreamFactory(), SystemInputStreamFactory.encoding()));
+ String document;
+ while ((document = documentStream.read()) != null) {
+ String[] tokens = WhitespaceTokenizer.INSTANCE.tokenize(document);
+
+ double[] prob = categorizer.categorize(tokens);
+ String category = categorizer.getBestCategory(prob);
+
+ DocumentSample sample = new DocumentSample(category, tokens);
+ logger.info(sample.toString());
+
+ perfMon.incrementCounter();
+ }
+ } catch (IOException e) {
+ CmdLineUtil.handleStdinIoError(e);
+ }
+
+ perfMon.stopAndPrintFinalResult();
+ }
+ }
+}
diff --git a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMTrainerTool.java b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMTrainerTool.java
new file mode 100644
index 00000000..cdd7a54b
--- /dev/null
+++ b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/doccat/DoccatSVMTrainerTool.java
@@ -0,0 +1,109 @@
+/*
+ * 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.cmdline.doccat;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.cmdline.AbstractTrainerTool;
+import opennlp.tools.cmdline.CmdLineUtil;
+import opennlp.tools.cmdline.doccat.DoccatSVMTrainerTool.SVMTrainerToolParams;
+import opennlp.tools.cmdline.params.TrainingToolParams;
+import opennlp.tools.doccat.BagOfWordsFeatureGenerator;
+import opennlp.tools.doccat.DocumentSample;
+import opennlp.tools.doccat.FeatureGenerator;
+import opennlp.tools.ml.libsvm.doccat.DocumentCategorizerSVM;
+import opennlp.tools.ml.libsvm.doccat.SvmDoccatConfiguration;
+import opennlp.tools.ml.libsvm.doccat.SvmDoccatModel;
+import opennlp.tools.util.ext.ExtensionLoader;
+
+/**
+ * CLI tool for training an SVM-based document categorization model.
+ * <p>
+ * Usage: {@code opennlp DoccatSVMTrainer -model model -lang lang -data sampleData [-featureGenerators fg]}
+ */
+public class DoccatSVMTrainerTool
+ extends AbstractTrainerTool<DocumentSample, SVMTrainerToolParams> {
+
+ private static final Logger logger = LoggerFactory.getLogger(DoccatSVMTrainerTool.class);
+
+ interface SVMTrainerToolParams extends TrainingParams, TrainingToolParams {
+ }
+
+ public DoccatSVMTrainerTool() {
+ super(DocumentSample.class, SVMTrainerToolParams.class);
+ }
+
+ @Override
+ public String getShortDescription() {
+ return "Trainer for the SVM-based document categorizer";
+ }
+
+ @Override
+ public void run(String format, String[] args) {
+ super.run(format, args);
+
+ File modelOutFile = params.getModel();
+ CmdLineUtil.checkOutputFile("SVM document categorizer model", modelOutFile);
+
+ FeatureGenerator[] featureGenerators = createFeatureGenerators(params.getFeatureGenerators());
+
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder().build();
+
+ SvmDoccatModel model;
+ try {
+ model = DocumentCategorizerSVM.train(params.getLang(), sampleStream, config,
+ featureGenerators);
+ } catch (IOException e) {
+ throw createTerminationIOException(e);
+ } finally {
+ try {
+ sampleStream.close();
+ } catch (IOException e) {
+ // sorry that this can fail
+ }
+ }
+
+ try (BufferedOutputStream modelOut = new BufferedOutputStream(
+ new FileOutputStream(modelOutFile))) {
+ model.serialize(modelOut);
+ } catch (IOException e) {
+ throw createTerminationIOException(e);
+ }
+
+ logger.info("SVM Doccat model written to: {}", modelOutFile.getAbsolutePath());
+ }
+
+ static FeatureGenerator[] createFeatureGenerators(String featureGeneratorsNames) {
+ if (featureGeneratorsNames == null) {
+ return new FeatureGenerator[]{new BagOfWordsFeatureGenerator()};
+ }
+ String[] classes = featureGeneratorsNames.split(",");
+ FeatureGenerator[] featureGenerators = new FeatureGenerator[classes.length];
+ for (int i = 0; i < featureGenerators.length; i++) {
+ featureGenerators[i] = ExtensionLoader.instantiateExtension(
+ FeatureGenerator.class, classes[i]);
+ }
+ return featureGenerators;
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/pom.xml b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/pom.xml
new file mode 100644
index 00000000..596e8a49
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/pom.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.apache.opennlp</groupId>
+ <artifactId>opennlp-ml</artifactId>
+ <version>3.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>opennlp-ml-libsvm</artifactId>
+ <packaging>jar</packaging>
+ <name>Apache OpenNLP :: Core :: ML :: LibSVM</name>
+
+ <dependencies>
+ <!-- Internal dependencies -->
+ <dependency>
+ <groupId>org.apache.opennlp</groupId>
+ <artifactId>opennlp-api</artifactId>
+ </dependency>
+
+ <!-- External dependencies -->
+ <dependency>
+ <groupId>de.hs-heilbronn.mi</groupId>
+ <artifactId>zlibsvm-core</artifactId>
+ <version>${zlibsvm.version}</version>
+ </dependency>
+
+ <!-- TEST scope: exclude logback from logcaptor to avoid duplicate SLF4J providers -->
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-simple</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>io.github.hakky54</groupId>
+ <artifactId>logcaptor</artifactId>
+ <version>${logcaptor.version}</version>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ </dependencies>
+
+</project>
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVM.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVM.java
new file mode 100644
index 00000000..726ae5ce
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVM.java
@@ -0,0 +1,410 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import de.hhn.mi.domain.SvmClassLabel;
+import de.hhn.mi.domain.SvmDocument;
+import de.hhn.mi.domain.SvmFeature;
+import de.hhn.mi.domain.SvmFeatureImpl;
+import de.hhn.mi.domain.SvmModel;
+import de.hhn.mi.process.SvmClassifier;
+import de.hhn.mi.process.SvmClassifierImpl;
+import de.hhn.mi.process.SvmTrainer;
+import de.hhn.mi.process.SvmTrainerImpl;
+
+import opennlp.tools.doccat.DocumentCategorizer;
+import opennlp.tools.doccat.DocumentSample;
+import opennlp.tools.doccat.FeatureGenerator;
+import opennlp.tools.util.ObjectStream;
+
+/**
+ * An implementation of {@link DocumentCategorizer} that uses Support Vector Machines
+ * (SVM) via the zlibsvm library for document classification.
+ * <p>
+ * This categorizer supports configurable:
+ * <ul>
+ * <li>{@link TermWeightingStrategy Term weighting} (binary, TF, TF-IDF, log-normalized TF)</li>
+ * <li>{@link FeatureSelectionStrategy Feature selection} (information gain, chi-square,
+ * term frequency, document frequency)</li>
+ * <li>Feature scaling to a configurable range (e.g., [0, 1])</li>
+ * <li>SVM classifier parameters (kernel, cost, gamma, etc.) via
+ * {@link de.hhn.mi.configuration.SvmConfiguration}</li>
+ * </ul>
+ *
+ * @see DocumentCategorizer
+ * @see SvmDoccatModel
+ * @see SvmDoccatConfiguration
+ */
+public class DocumentCategorizerSVM implements DocumentCategorizer {
+
+ private final SvmDoccatModel model;
+ private final FeatureGenerator[] featureGenerators;
+ private final SvmClassifier classifier;
+
+ /**
+ * Instantiates a {@link DocumentCategorizerSVM} with a trained model and feature generators.
+ *
+ * @param model The trained {@link SvmDoccatModel}. Must not be {@code null}.
+ * @param featureGenerators The {@link FeatureGenerator} instances used to extract features.
+ * Must not be {@code null} or empty.
+ */
+ public DocumentCategorizerSVM(SvmDoccatModel model, FeatureGenerator... featureGenerators) {
+ this.model = Objects.requireNonNull(model, "model must not be null");
+ Objects.requireNonNull(featureGenerators, "featureGenerators must not be null");
+ if (featureGenerators.length == 0) {
+ throw new IllegalArgumentException("At least one FeatureGenerator is required");
+ }
+ this.featureGenerators = featureGenerators;
+ this.classifier = new SvmClassifierImpl(model.getSvmModel());
+ }
+
+ @Override
+ public double[] categorize(String[] text, Map<String, Object> extraInformation) {
+ SvmDocument doc = textToSvmDocument(text, extraInformation);
+ List<SvmDocument> classified = classifier.classify(List.of(doc), true);
+ return toOutcomeProbabilities(classified.get(0));
+ }
+
+ @Override
+ public double[] categorize(String[] text) {
+ return categorize(text, Collections.emptyMap());
+ }
+
+ @Override
+ public String getBestCategory(double[] outcome) {
+ int bestIndex = 0;
+ for (int i = 1; i < outcome.length; i++) {
+ if (outcome[i] > outcome[bestIndex]) {
+ bestIndex = i;
+ }
+ }
+ return model.getIndexToCategory().get(bestIndex);
+ }
+
+ @Override
+ public int getIndex(String category) {
+ Integer idx = model.getCategoryToIndex().get(category);
+ return idx != null ? idx : -1;
+ }
+
+ @Override
+ public String getCategory(int index) {
+ return model.getIndexToCategory().get(index);
+ }
+
+ @Override
+ public int getNumberOfCategories() {
+ return model.getNumberOfCategories();
+ }
+
+ @Override
+ public String getAllResults(double[] results) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < results.length; i++) {
+ if (i > 0) {
+ sb.append(' ');
+ }
+ sb.append(model.getIndexToCategory().get(i)).append('[').append(results[i]).append(']');
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public Map<String, Double> scoreMap(String[] text) {
+ double[] scores = categorize(text);
+ Map<String, Double> scoreMap = new HashMap<>();
+ for (int i = 0; i < scores.length; i++) {
+ scoreMap.put(getCategory(i), scores[i]);
+ }
+ return scoreMap;
+ }
+
+ @Override
+ public SortedMap<Double, Set<String>> sortedScoreMap(String[] text) {
+ double[] scores = categorize(text);
+ SortedMap<Double, Set<String>> sortedMap = new TreeMap<>();
+ for (int i = 0; i < scores.length; i++) {
+ String category = getCategory(i);
+ sortedMap.computeIfAbsent(scores[i], k -> new HashSet<>()).add(category);
+ }
+ return sortedMap;
+ }
+
+ /**
+ * Trains an SVM-based document categorization model using default configuration
+ * (TF-IDF weighting, no feature selection, scaling to [0, 1]).
+ *
+ * @param lang The ISO conform language code.
+ * @param samples The {@link ObjectStream} of {@link DocumentSample} used as input
+ * for training.
+ * @param featureGenerators The {@link FeatureGenerator} instances used to extract features.
+ * @return A trained {@link SvmDoccatModel}.
+ * @throws IOException Thrown if IO errors occurred during training.
+ */
+ public static SvmDoccatModel train(String lang,
+ ObjectStream<DocumentSample> samples,
+ FeatureGenerator... featureGenerators) throws IOException {
+ return train(lang, samples, new SvmDoccatConfiguration.Builder().build(), featureGenerators);
+ }
+
+ /**
+ * Trains an SVM-based document categorization model with a custom configuration.
+ *
+ * @param lang The ISO conform language code.
+ * @param samples The {@link ObjectStream} of {@link DocumentSample} used as input
+ * for training.
+ * @param config The {@link SvmDoccatConfiguration} controlling term weighting,
+ * feature selection, scaling, and SVM parameters.
+ * @param featureGenerators The {@link FeatureGenerator} instances used to extract features.
+ * @return A trained {@link SvmDoccatModel}.
+ * @throws IOException Thrown if IO errors occurred during training.
+ */
+ public static SvmDoccatModel train(String lang,
+ ObjectStream<DocumentSample> samples,
+ SvmDoccatConfiguration config,
+ FeatureGenerator... featureGenerators) throws IOException {
+ Objects.requireNonNull(samples, "samples must not be null");
+ Objects.requireNonNull(config, "config must not be null");
+ Objects.requireNonNull(featureGenerators, "featureGenerators must not be null");
+ if (featureGenerators.length == 0) {
+ throw new IllegalArgumentException("At least one FeatureGenerator is required");
+ }
+
+ // --- Pass 1: Collect all samples, build vocabulary + category mappings ---
+ Map<String, Integer> categoryToIndex = new LinkedHashMap<>();
+ Map<Integer, String> indexToCategory = new HashMap<>();
+ List<DocumentSample> allSamples = new ArrayList<>();
+
+ // Per-document: feature string set and term frequency map
+ List<Set<String>> docFeatureSets = new ArrayList<>();
+ List<Map<String, Integer>> docTfMaps = new ArrayList<>();
+ List<String> docLabels = new ArrayList<>();
+
+ // Corpus-level document frequency
+ Map<String, Integer> documentFrequency = new HashMap<>();
+ int totalDocs = 0;
+
+ DocumentSample sample;
+ while ((sample = samples.read()) != null) {
+ allSamples.add(sample);
+ totalDocs++;
+
+ // Register category
+ String category = sample.getCategory();
+ if (!categoryToIndex.containsKey(category)) {
+ int catIdx = categoryToIndex.size();
+ categoryToIndex.put(category, catIdx);
+ indexToCategory.put(catIdx, category);
+ }
+ docLabels.add(category);
+
+ // Extract features
+ String[] context = generateContext(sample.getText(), sample.getExtraInformation(),
+ featureGenerators);
+
+ // Compute term frequency for this document
+ Map<String, Integer> tf = new HashMap<>();
+ for (String feature : context) {
+ tf.merge(feature, 1, Integer::sum);
+ }
+ docTfMaps.add(tf);
+ docFeatureSets.add(tf.keySet());
+
+ // Update document frequency
+ for (String feature : tf.keySet()) {
+ documentFrequency.merge(feature, 1, Integer::sum);
+ }
+ }
+
+ // --- Pass 2: Feature selection ---
+ Set<String> selectedFeatures;
+ if (config.getFeatureSelectionStrategy() != FeatureSelectionStrategy.NONE) {
+ selectedFeatures = FeatureSelection.selectTopFeatures(
+ docFeatureSets, docTfMaps, docLabels,
+ config.getFeatureSelectionStrategy(), config.getMaxFeatures());
+ } else {
+ selectedFeatures = new HashSet<>();
+ for (Set<String> fs : docFeatureSets) {
+ selectedFeatures.addAll(fs);
+ }
+ }
+
+ // Build final vocabulary (only selected features)
+ Map<String, Integer> featureVocabulary = new LinkedHashMap<>();
+ for (String feature : selectedFeatures) {
+ featureVocabulary.put(feature, featureVocabulary.size() + 1);
+ }
+
+ // --- Pass 3: Compute IDF values ---
+ Map<String, Double> idfValues = new HashMap<>();
+ for (String feature : featureVocabulary.keySet()) {
+ int df = documentFrequency.getOrDefault(feature, 0);
+ idfValues.put(feature, df > 0 ? Math.log((double) totalDocs / df) : 0.0);
+ }
+
+ // --- Pass 4: Compute weighted features & collect min/max for scaling ---
+ TermWeightingStrategy weighting = config.getTermWeightingStrategy();
+ List<Map<Integer, Double>> docWeightedFeatures = new ArrayList<>(allSamples.size());
+
+ // Initialize min to 0.0 for all features (absent features have implicit value 0)
+ Map<Integer, Double> featureMin = new HashMap<>();
+ Map<Integer, Double> featureMax = new HashMap<>();
+ for (Integer idx : featureVocabulary.values()) {
+ featureMin.put(idx, 0.0);
+ featureMax.put(idx, 0.0);
+ }
+
+ for (Map<String, Integer> tf : docTfMaps) {
+ Map<Integer, Double> weighted = new TreeMap<>();
+ for (Map.Entry<String, Integer> entry : tf.entrySet()) {
+ String feature = entry.getKey();
+ Integer idx = featureVocabulary.get(feature);
+ if (idx != null) {
+ double idf = idfValues.getOrDefault(feature, 0.0);
+ double value = weighting.weight(entry.getValue(), idf);
+ weighted.put(idx, value);
+
+ featureMin.merge(idx, value, Math::min);
+ featureMax.merge(idx, value, Math::max);
+ }
+ }
+ docWeightedFeatures.add(weighted);
+ }
+
+ // --- Pass 5: Scale and build SVM documents ---
+ List<SvmDocument> svmDocuments = new ArrayList<>(allSamples.size());
+ for (int i = 0; i < allSamples.size(); i++) {
+ Map<Integer, Double> weighted = docWeightedFeatures.get(i);
+ List<SvmFeature> features = buildFeatureVector(weighted, config, featureMin, featureMax);
+
+ SvmDocument svmDoc = new TrainingSvmDocument(features);
+ int categoryLabel = categoryToIndex.get(allSamples.get(i).getCategory());
+ svmDoc.addClassLabel(new TrainingSvmClassLabel(categoryLabel,
+ allSamples.get(i).getCategory(), 1.0));
+ svmDocuments.add(svmDoc);
+ }
+
+ // --- Train SVM ---
+ SvmTrainer trainer = new SvmTrainerImpl(config.getSvmConfiguration(), "opennlp-doccat-svm");
+ SvmModel svmModel = trainer.train(svmDocuments);
+
+ return new SvmDoccatModel(svmModel, featureVocabulary, indexToCategory, categoryToIndex,
+ idfValues, featureMin, featureMax, config, lang);
+ }
+
+ // --- Private helpers ---
+
+ private SvmDocument textToSvmDocument(String[] text, Map<String, Object> extraInformation) {
+ String[] context = generateContext(text, extraInformation, featureGenerators);
+ SvmDoccatConfiguration config = model.getConfiguration();
+ TermWeightingStrategy weighting = config.getTermWeightingStrategy();
+ Map<String, Integer> vocab = model.getFeatureVocabulary();
+ Map<String, Double> idf = model.getIdfValues();
+
+ // Compute term frequencies
+ Map<Integer, Double> weighted = new TreeMap<>();
+ Map<String, Integer> tf = new HashMap<>();
+ for (String feature : context) {
+ tf.merge(feature, 1, Integer::sum);
+ }
+
+ for (Map.Entry<String, Integer> entry : tf.entrySet()) {
+ Integer idx = vocab.get(entry.getKey());
+ if (idx != null) {
+ double idfVal = idf.getOrDefault(entry.getKey(), 0.0);
+ double value = weighting.weight(entry.getValue(), idfVal);
+ if (Double.compare(value, 0.0) != 0) {
+ weighted.put(idx, value);
+ }
+ }
+ }
+
+ List<SvmFeature> features = buildFeatureVector(weighted, config,
+ model.getFeatureMinValues(), model.getFeatureMaxValues());
+ return new TrainingSvmDocument(features);
+ }
+
+ private static List<SvmFeature> buildFeatureVector(Map<Integer, Double> weighted,
+ SvmDoccatConfiguration config,
+ Map<Integer, Double> featureMin,
+ Map<Integer, Double> featureMax) {
+ // Only emit non-zero features (sparse representation required by libsvm)
+ List<SvmFeature> features = new ArrayList<>(weighted.size());
+ for (Map.Entry<Integer, Double> entry : weighted.entrySet()) {
+ double value = entry.getValue();
+ if (config.isScaleFeatures()) {
+ value = scale(value, entry.getKey(), featureMin, featureMax,
+ config.getScaleLower(), config.getScaleUpper());
+ }
+ if (Double.compare(value, 0.0) != 0) {
+ features.add(new SvmFeatureImpl(entry.getKey(), value));
+ }
+ }
+ return features;
+ }
+
+ private static double scale(double value, int featureIdx,
+ Map<Integer, Double> featureMin,
+ Map<Integer, Double> featureMax,
+ double lower, double upper) {
+ double min = featureMin.getOrDefault(featureIdx, 0.0);
+ double max = featureMax.getOrDefault(featureIdx, 0.0);
+ if (Double.compare(max, min) == 0) {
+ return lower;
+ }
+ // Clamp to training range, then scale
+ double clamped = Math.max(min, Math.min(max, value));
+ return lower + (upper - lower) * (clamped - min) / (max - min);
+ }
+
+ private double[] toOutcomeProbabilities(SvmDocument classifiedDoc) {
+ int numCategories = model.getNumberOfCategories();
+ double[] probs = new double[numCategories];
+ for (SvmClassLabel label : classifiedDoc.getAllClassLabels()) {
+ int numericLabel = (int) label.getNumeric();
+ if (numericLabel >= 0 && numericLabel < numCategories) {
+ probs[numericLabel] = label.getProbability();
+ }
+ }
+ return probs;
+ }
+
+ private static String[] generateContext(String[] text, Map<String, Object> extraInformation,
+ FeatureGenerator[] generators) {
+ List<String> context = new ArrayList<>();
+ for (FeatureGenerator gen : generators) {
+ Collection<String> features = gen.extractFeatures(text, extraInformation);
+ context.addAll(features);
+ }
+ return context.toArray(new String[0]);
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/FeatureSelection.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/FeatureSelection.java
new file mode 100644
index 00000000..e875f7db
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/FeatureSelection.java
@@ -0,0 +1,234 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Computes feature scores and selects the top-k features for text classification.
+ * <p>
+ * Supports multiple feature selection strategies:
+ * <ul>
+ * <li>{@link FeatureSelectionStrategy#INFORMATION_GAIN} - ranks by entropy reduction</li>
+ * <li>{@link FeatureSelectionStrategy#CHI_SQUARE} - ranks by chi-square independence test</li>
+ * <li>{@link FeatureSelectionStrategy#TERM_FREQUENCY} - ranks by total corpus frequency</li>
+ * <li>{@link FeatureSelectionStrategy#DOCUMENT_FREQUENCY} - ranks by number of containing documents</li>
+ * </ul>
+ *
+ * @see FeatureSelectionStrategy
+ */
+final class FeatureSelection {
+
+ private FeatureSelection() {
+ }
+
+ /**
+ * Selects the top-k features according to the given strategy.
+ *
+ * @param documentFeatures A list where each entry is the set of feature strings
+ * present in a document.
+ * @param documentTfs A list where each entry maps feature strings to their
+ * term frequency in that document.
+ * @param documentLabels A list of category labels, one per document. Must have
+ * the same size as {@code documentFeatures}.
+ * @param strategy The feature selection strategy.
+ * @param maxFeatures The maximum number of features to select. If {@code <= 0},
+ * all features are returned.
+ * @return The set of selected feature strings.
+ */
+ static Set<String> selectTopFeatures(List<Set<String>> documentFeatures,
+ List<Map<String, Integer>> documentTfs,
+ List<String> documentLabels,
+ FeatureSelectionStrategy strategy,
+ int maxFeatures) {
+ Map<String, Double> scores = switch (strategy) {
+ case INFORMATION_GAIN -> computeInformationGain(documentFeatures, documentLabels);
+ case CHI_SQUARE -> computeChiSquare(documentFeatures, documentLabels);
+ case TERM_FREQUENCY -> computeTermFrequency(documentTfs);
+ case DOCUMENT_FREQUENCY -> computeDocumentFrequency(documentFeatures);
+ case NONE -> throw new IllegalArgumentException("NONE strategy does not require feature selection");
+ };
+
+ if (maxFeatures <= 0 || maxFeatures >= scores.size()) {
+ return scores.keySet();
+ }
+
+ return scores.entrySet().stream()
+ .limit(maxFeatures)
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toSet());
+ }
+
+ /**
+ * Computes Information Gain scores.
+ * <p>
+ * {@code IG(F) = H(C) - H(C|F)} where {@code H(C)} is the class entropy and
+ * {@code H(C|F)} is the conditional entropy given feature presence/absence.
+ */
+ static Map<String, Double> computeInformationGain(List<Set<String>> documentFeatures,
+ List<String> documentLabels) {
+ int n = documentFeatures.size();
+
+ Map<String, Integer> classCounts = new HashMap<>();
+ for (String label : documentLabels) {
+ classCounts.merge(label, 1, Integer::sum);
+ }
+ double classEntropy = entropy(classCounts, n);
+
+ Set<String> allFeatures = collectAllFeatures(documentFeatures);
+ Map<String, Double> scores = new HashMap<>();
+
+ for (String feature : allFeatures) {
+ Map<String, Integer> presentCounts = new HashMap<>();
+ Map<String, Integer> absentCounts = new HashMap<>();
+ int presentTotal = 0;
+
+ for (int i = 0; i < n; i++) {
+ String label = documentLabels.get(i);
+ if (documentFeatures.get(i).contains(feature)) {
+ presentCounts.merge(label, 1, Integer::sum);
+ presentTotal++;
+ } else {
+ absentCounts.merge(label, 1, Integer::sum);
+ }
+ }
+
+ int absentTotal = n - presentTotal;
+ double condEntropy = 0.0;
+ if (presentTotal > 0) {
+ condEntropy += ((double) presentTotal / n) * entropy(presentCounts, presentTotal);
+ }
+ if (absentTotal > 0) {
+ condEntropy += ((double) absentTotal / n) * entropy(absentCounts, absentTotal);
+ }
+
+ scores.put(feature, classEntropy - condEntropy);
+ }
+
+ return sortDescending(scores);
+ }
+
+ /**
+ * Computes Chi-Square scores.
+ * <p>
+ * For each feature, computes the maximum chi-square statistic across all classes:
+ * <pre>
+ * chi2(F, C) = N * (AD - BC)^2 / ((A+B)(C+D)(A+C)(B+D))
+ * </pre>
+ * where A = docs with F in class C, B = docs with F not in C,
+ * C = docs without F in class C, D = docs without F not in C.
+ */
+ static Map<String, Double> computeChiSquare(List<Set<String>> documentFeatures,
+ List<String> documentLabels) {
+ int n = documentFeatures.size();
+
+ Set<String> allClasses = new HashSet<>(documentLabels);
+ Set<String> allFeatures = collectAllFeatures(documentFeatures);
+ Map<String, Double> scores = new HashMap<>();
+
+ for (String feature : allFeatures) {
+ double maxChi2 = 0.0;
+
+ for (String clazz : allClasses) {
+ int a = 0; // feature present, class match
+ int b = 0; // feature present, class no match
+ int c = 0; // feature absent, class match
+ int d = 0; // feature absent, class no match
+
+ for (int i = 0; i < n; i++) {
+ boolean hasFeature = documentFeatures.get(i).contains(feature);
+ boolean isClass = documentLabels.get(i).equals(clazz);
+
+ if (hasFeature && isClass) a++;
+ else if (hasFeature) b++;
+ else if (isClass) c++;
+ else d++;
+ }
+
+ double numerator = (double) n * Math.pow((double) a * d - (double) b * c, 2);
+ double denominator = (double) (a + b) * (c + d) * (a + c) * (b + d);
+ if (denominator > 0) {
+ maxChi2 = Math.max(maxChi2, numerator / denominator);
+ }
+ }
+
+ scores.put(feature, maxChi2);
+ }
+
+ return sortDescending(scores);
+ }
+
+ /**
+ * Computes total Term Frequency scores across the corpus.
+ */
+ static Map<String, Double> computeTermFrequency(List<Map<String, Integer>> documentTfs) {
+ Map<String, Double> scores = new HashMap<>();
+ for (Map<String, Integer> docTf : documentTfs) {
+ for (Map.Entry<String, Integer> entry : docTf.entrySet()) {
+ scores.merge(entry.getKey(), (double) entry.getValue(), Double::sum);
+ }
+ }
+ return sortDescending(scores);
+ }
+
+ /**
+ * Computes Document Frequency scores (number of documents containing each feature).
+ */
+ static Map<String, Double> computeDocumentFrequency(List<Set<String>> documentFeatures) {
+ Map<String, Double> scores = new HashMap<>();
+ for (Set<String> docFeats : documentFeatures) {
+ for (String feature : docFeats) {
+ scores.merge(feature, 1.0, Double::sum);
+ }
+ }
+ return sortDescending(scores);
+ }
+
+ private static Set<String> collectAllFeatures(List<Set<String>> documentFeatures) {
+ Set<String> all = new HashSet<>();
+ for (Set<String> docFeats : documentFeatures) {
+ all.addAll(docFeats);
+ }
+ return all;
+ }
+
+ private static double entropy(Map<String, Integer> counts, int total) {
+ double h = 0.0;
+ for (int count : counts.values()) {
+ if (count > 0) {
+ double p = (double) count / total;
+ h -= p * Math.log(p);
+ }
+ }
+ return h;
+ }
+
+ private static Map<String, Double> sortDescending(Map<String, Double> scores) {
+ return scores.entrySet().stream()
+ .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
+ .collect(Collectors.toMap(
+ Map.Entry::getKey, Map.Entry::getValue,
+ (a, b) -> a, LinkedHashMap::new));
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/FeatureSelectionStrategy.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/FeatureSelectionStrategy.java
new file mode 100644
index 00000000..c3b4b0f4
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/FeatureSelectionStrategy.java
@@ -0,0 +1,77 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+/**
+ * Defines strategies for selecting the most informative features for
+ * SVM-based text classification.
+ * <p>
+ * Feature selection reduces the dimensionality of the feature space by
+ * retaining only the features that are most useful for distinguishing
+ * between categories.
+ *
+ * @see DocumentCategorizerSVM
+ * @see FeatureSelection
+ */
+public enum FeatureSelectionStrategy {
+
+ /**
+ * No feature selection: all features from the vocabulary are used.
+ */
+ NONE,
+
+ /**
+ * Information Gain based feature selection: features are ranked by their
+ * information gain score, and only the top-k features are retained.
+ * <p>
+ * Information gain measures the reduction in entropy of the class variable
+ * achieved by observing the presence or absence of a feature.
+ */
+ INFORMATION_GAIN,
+
+ /**
+ * Chi-Square based feature selection: features are ranked by the maximum
+ * chi-square statistic across all categories, and only the top-k features
+ * are retained.
+ * <p>
+ * Chi-square measures the statistical dependence between a feature and
+ * a class label. A high chi-square value indicates that the feature
+ * and the class are not independent.
+ */
+ CHI_SQUARE,
+
+ /**
+ * Term Frequency based feature selection: features are ranked by their
+ * total occurrence count across all documents in the corpus, and only
+ * the top-k features are retained.
+ * <p>
+ * This is a simple baseline strategy that favors frequent terms.
+ * It can be useful to filter out very rare features that may be noise.
+ */
+ TERM_FREQUENCY,
+
+ /**
+ * Document Frequency based feature selection: features are ranked by the
+ * number of documents they appear in, and only the top-k features are
+ * retained.
+ * <p>
+ * Unlike {@link #TERM_FREQUENCY}, this counts each feature at most once
+ * per document, regardless of how often it occurs within that document.
+ */
+ DOCUMENT_FREQUENCY
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatConfiguration.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatConfiguration.java
new file mode 100644
index 00000000..52515ae2
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatConfiguration.java
@@ -0,0 +1,217 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Objects;
+
+import de.hhn.mi.configuration.SvmConfiguration;
+import de.hhn.mi.configuration.SvmConfigurationImpl;
+
+/**
+ * Configuration for SVM-based document categorization, combining the underlying
+ * SVM classifier settings with text-specific parameters for term weighting,
+ * feature selection, and feature scaling.
+ *
+ * @see DocumentCategorizerSVM
+ * @see TermWeightingStrategy
+ * @see FeatureSelectionStrategy
+ */
+public class SvmDoccatConfiguration implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final SvmConfiguration svmConfiguration;
+ private final TermWeightingStrategy termWeightingStrategy;
+ private final FeatureSelectionStrategy featureSelectionStrategy;
+ private final int maxFeatures;
+ private final boolean scaleFeatures;
+ private final double scaleLower;
+ private final double scaleUpper;
+
+ private SvmDoccatConfiguration(Builder builder) {
+ this.svmConfiguration = builder.svmConfiguration;
+ this.termWeightingStrategy = builder.termWeightingStrategy;
+ this.featureSelectionStrategy = builder.featureSelectionStrategy;
+ this.maxFeatures = builder.maxFeatures;
+ this.scaleFeatures = builder.scaleFeatures;
+ this.scaleLower = builder.scaleLower;
+ this.scaleUpper = builder.scaleUpper;
+ }
+
+ /**
+ * @return The underlying SVM configuration for the classifier/trainer.
+ */
+ public SvmConfiguration getSvmConfiguration() {
+ return svmConfiguration;
+ }
+
+ /**
+ * @return The term weighting strategy used to compute feature values.
+ */
+ public TermWeightingStrategy getTermWeightingStrategy() {
+ return termWeightingStrategy;
+ }
+
+ /**
+ * @return The feature selection strategy used to reduce the feature space.
+ */
+ public FeatureSelectionStrategy getFeatureSelectionStrategy() {
+ return featureSelectionStrategy;
+ }
+
+ /**
+ * @return The maximum number of features to select. A value {@code <= 0} means
+ * all features are retained.
+ */
+ public int getMaxFeatures() {
+ return maxFeatures;
+ }
+
+ /**
+ * @return {@code true} if feature values should be scaled to the configured range.
+ */
+ public boolean isScaleFeatures() {
+ return scaleFeatures;
+ }
+
+ /**
+ * @return The lower bound of the scaling range (default: {@code 0.0}).
+ */
+ public double getScaleLower() {
+ return scaleLower;
+ }
+
+ /**
+ * @return The upper bound of the scaling range (default: {@code 1.0}).
+ */
+ public double getScaleUpper() {
+ return scaleUpper;
+ }
+
+ /**
+ * A builder for {@link SvmDoccatConfiguration}.
+ */
+ public static class Builder {
+
+ private SvmConfiguration svmConfiguration = new SvmConfigurationImpl.Builder()
+ .setProbability(true)
+ .build();
+ private TermWeightingStrategy termWeightingStrategy = TermWeightingStrategy.TF_IDF;
+ private FeatureSelectionStrategy featureSelectionStrategy = FeatureSelectionStrategy.NONE;
+ private int maxFeatures = -1;
+ private boolean scaleFeatures = true;
+ private double scaleLower = 0.0;
+ private double scaleUpper = 1.0;
+
+ /**
+ * Sets the underlying SVM configuration.
+ * <p>
+ * Note: Probability estimates must be enabled in the SVM configuration
+ * for the categorizer to produce probability distributions over categories.
+ *
+ * @param svmConfiguration The SVM configuration. Must not be {@code null}.
+ * @return This builder.
+ */
+ public Builder setSvmConfiguration(SvmConfiguration svmConfiguration) {
+ this.svmConfiguration = Objects.requireNonNull(svmConfiguration,
+ "svmConfiguration must not be null");
+ return this;
+ }
+
+ /**
+ * Sets the term weighting strategy.
+ *
+ * @param strategy The weighting strategy. Must not be {@code null}.
+ * Default: {@link TermWeightingStrategy#TF_IDF}.
+ * @return This builder.
+ */
+ public Builder setTermWeightingStrategy(TermWeightingStrategy strategy) {
+ this.termWeightingStrategy = Objects.requireNonNull(strategy,
+ "strategy must not be null");
+ return this;
+ }
+
+ /**
+ * Sets the feature selection strategy.
+ *
+ * @param strategy The selection strategy. Must not be {@code null}.
+ * Default: {@link FeatureSelectionStrategy#NONE}.
+ * @return This builder.
+ */
+ public Builder setFeatureSelectionStrategy(FeatureSelectionStrategy strategy) {
+ this.featureSelectionStrategy = Objects.requireNonNull(strategy,
+ "strategy must not be null");
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of features to retain after feature selection.
+ * Only applicable when {@link FeatureSelectionStrategy} is not
+ * {@link FeatureSelectionStrategy#NONE}.
+ *
+ * @param maxFeatures The maximum number of features. A value {@code <= 0}
+ * means all features are retained. Default: {@code -1}.
+ * @return This builder.
+ */
+ public Builder setMaxFeatures(int maxFeatures) {
+ this.maxFeatures = maxFeatures;
+ return this;
+ }
+
+ /**
+ * Sets whether feature values should be scaled to the range
+ * [{@code lower}, {@code upper}].
+ *
+ * @param scaleFeatures {@code true} to enable feature scaling. Default: {@code true}.
+ * @return This builder.
+ */
+ public Builder setScaleFeatures(boolean scaleFeatures) {
+ this.scaleFeatures = scaleFeatures;
+ return this;
+ }
+
+ /**
+ * Sets the lower and upper bounds of the feature scaling range.
+ * Only applicable when {@link #setScaleFeatures(boolean)} is {@code true}.
+ *
+ * @param lower The lower bound. Default: {@code 0.0}.
+ * @param upper The upper bound. Must be greater than {@code lower}.
+ * Default: {@code 1.0}.
+ * @return This builder.
+ * @throws IllegalArgumentException if {@code upper <= lower}.
+ */
+ public Builder setScaleRange(double lower, double upper) {
+ if (upper <= lower) {
+ throw new IllegalArgumentException("upper must be greater than lower");
+ }
+ this.scaleLower = lower;
+ this.scaleUpper = upper;
+ return this;
+ }
+
+ /**
+ * @return A fully configured {@link SvmDoccatConfiguration}.
+ */
+ public SvmDoccatConfiguration build() {
+ return new SvmDoccatConfiguration(this);
+ }
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatModel.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatModel.java
new file mode 100644
index 00000000..b1ed01d0
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatModel.java
@@ -0,0 +1,199 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import de.hhn.mi.domain.SvmModel;
+
+/**
+ * A model for SVM-based document categorization. This model wraps a zlibsvm
+ * {@link SvmModel} together with the feature vocabulary, category label
+ * mappings, corpus statistics, and configuration required for classification.
+ *
+ * @see DocumentCategorizerSVM
+ */
+public class SvmDoccatModel implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 2L;
+
+ private final SvmModel svmModel;
+ private final HashMap<String, Integer> featureVocabulary;
+ private final HashMap<Integer, String> indexToCategory;
+ private final HashMap<String, Integer> categoryToIndex;
+ private final HashMap<String, Double> idfValues;
+ private final HashMap<Integer, Double> featureMinValues;
+ private final HashMap<Integer, Double> featureMaxValues;
+ private final SvmDoccatConfiguration configuration;
+ private final String languageCode;
+
+ /**
+ * Instantiates a {@link SvmDoccatModel} with the given parameters.
+ *
+ * @param svmModel The trained {@link SvmModel}. Must not be {@code null}.
+ * @param featureVocabulary A mapping from feature strings to their numeric indices.
+ * Must not be {@code null}.
+ * @param indexToCategory A mapping from numeric category labels to category names.
+ * Must not be {@code null}.
+ * @param categoryToIndex A mapping from category names to numeric labels.
+ * Must not be {@code null}.
+ * @param idfValues A mapping from feature strings to their IDF values.
+ * Must not be {@code null}.
+ * @param featureMinValues A mapping from feature index to its minimum value in the
+ * training corpus (for scaling). Must not be {@code null}.
+ * @param featureMaxValues A mapping from feature index to its maximum value in the
+ * training corpus (for scaling). Must not be {@code null}.
+ * @param configuration The {@link SvmDoccatConfiguration} used for training.
+ * Must not be {@code null}.
+ * @param languageCode An ISO conform language code.
+ */
+ SvmDoccatModel(SvmModel svmModel,
+ Map<String, Integer> featureVocabulary,
+ Map<Integer, String> indexToCategory,
+ Map<String, Integer> categoryToIndex,
+ Map<String, Double> idfValues,
+ Map<Integer, Double> featureMinValues,
+ Map<Integer, Double> featureMaxValues,
+ SvmDoccatConfiguration configuration,
+ String languageCode) {
+ this.svmModel = Objects.requireNonNull(svmModel, "svmModel must not be null");
+ this.featureVocabulary = new HashMap<>(
+ Objects.requireNonNull(featureVocabulary, "featureVocabulary must not be null"));
+ this.indexToCategory = new HashMap<>(
+ Objects.requireNonNull(indexToCategory, "indexToCategory must not be null"));
+ this.categoryToIndex = new HashMap<>(
+ Objects.requireNonNull(categoryToIndex, "categoryToIndex must not be null"));
+ this.idfValues = new HashMap<>(
+ Objects.requireNonNull(idfValues, "idfValues must not be null"));
+ this.featureMinValues = new HashMap<>(
+ Objects.requireNonNull(featureMinValues, "featureMinValues must not be null"));
+ this.featureMaxValues = new HashMap<>(
+ Objects.requireNonNull(featureMaxValues, "featureMaxValues must not be null"));
+ this.configuration = Objects.requireNonNull(configuration, "configuration must not be null");
+ this.languageCode = languageCode;
+ }
+
+ /**
+ * @return The underlying {@link SvmModel}.
+ */
+ public SvmModel getSvmModel() {
+ return svmModel;
+ }
+
+ /**
+ * @return An unmodifiable mapping from feature strings to their numeric indices.
+ */
+ public Map<String, Integer> getFeatureVocabulary() {
+ return Collections.unmodifiableMap(featureVocabulary);
+ }
+
+ /**
+ * @return An unmodifiable mapping from numeric category labels to category names.
+ */
+ public Map<Integer, String> getIndexToCategory() {
+ return Collections.unmodifiableMap(indexToCategory);
+ }
+
+ /**
+ * @return An unmodifiable mapping from category names to numeric labels.
+ */
+ public Map<String, Integer> getCategoryToIndex() {
+ return Collections.unmodifiableMap(categoryToIndex);
+ }
+
+ /**
+ * @return An unmodifiable mapping from feature strings to their IDF values.
+ */
+ public Map<String, Double> getIdfValues() {
+ return Collections.unmodifiableMap(idfValues);
+ }
+
+ /**
+ * @return An unmodifiable mapping from feature index to its minimum value in the
+ * training corpus.
+ */
+ public Map<Integer, Double> getFeatureMinValues() {
+ return Collections.unmodifiableMap(featureMinValues);
+ }
+
+ /**
+ * @return An unmodifiable mapping from feature index to its maximum value in the
+ * training corpus.
+ */
+ public Map<Integer, Double> getFeatureMaxValues() {
+ return Collections.unmodifiableMap(featureMaxValues);
+ }
+
+ /**
+ * @return The {@link SvmDoccatConfiguration} used for this model.
+ */
+ public SvmDoccatConfiguration getConfiguration() {
+ return configuration;
+ }
+
+ /**
+ * @return The ISO language code associated with this model.
+ */
+ public String getLanguageCode() {
+ return languageCode;
+ }
+
+ /**
+ * @return The number of categories in this model.
+ */
+ public int getNumberOfCategories() {
+ return indexToCategory.size();
+ }
+
+ /**
+ * Serializes this model to the given {@link OutputStream}.
+ *
+ * @param out The {@link OutputStream} to write to. Must not be {@code null}.
+ * @throws IOException Thrown if IO errors occurred during serialization.
+ */
+ public void serialize(OutputStream out) throws IOException {
+ try (ObjectOutputStream oos = new ObjectOutputStream(out)) {
+ oos.writeObject(this);
+ }
+ }
+
+ /**
+ * Deserializes a {@link SvmDoccatModel} from the given {@link InputStream}.
+ *
+ * @param in The {@link InputStream} to read from. Must not be {@code null}.
+ * @return A valid {@link SvmDoccatModel} instance.
+ * @throws IOException Thrown if IO errors occurred during deserialization.
+ * @throws ClassNotFoundException Thrown if required classes are not found.
+ */
+ public static SvmDoccatModel deserialize(InputStream in) throws IOException, ClassNotFoundException {
+ try (ObjectInputStream ois = new ObjectInputStream(in)) {
+ return (SvmDoccatModel) ois.readObject();
+ }
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TermWeightingStrategy.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TermWeightingStrategy.java
new file mode 100644
index 00000000..acd21681
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TermWeightingStrategy.java
@@ -0,0 +1,88 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+/**
+ * Defines strategies for weighting term features in SVM-based text classification.
+ * <p>
+ * The weighting strategy determines how raw term occurrences are converted
+ * into numeric feature values for the SVM feature vectors.
+ *
+ * @see DocumentCategorizerSVM
+ */
+public enum TermWeightingStrategy {
+
+ /**
+ * Binary weighting: {@code 1.0} if the term is present in the document,
+ * {@code 0.0} otherwise. Ignores term frequency.
+ */
+ BINARY {
+ @Override
+ public double weight(int termFrequency, double inverseDocumentFrequency) {
+ return termFrequency > 0 ? 1.0 : 0.0;
+ }
+ },
+
+ /**
+ * Raw term frequency: the number of times a term occurs in a document.
+ */
+ TERM_FREQUENCY {
+ @Override
+ public double weight(int termFrequency, double inverseDocumentFrequency) {
+ return termFrequency;
+ }
+ },
+
+ /**
+ * TF-IDF (Term Frequency - Inverse Document Frequency):
+ * {@code tf * log(N / df)}, where {@code N} is the total number of documents
+ * and {@code df} is the number of documents containing the term.
+ * <p>
+ * This downweights terms that appear in many documents (common terms)
+ * and upweights terms that are discriminative.
+ */
+ TF_IDF {
+ @Override
+ public double weight(int termFrequency, double inverseDocumentFrequency) {
+ return termFrequency * inverseDocumentFrequency;
+ }
+ },
+
+ /**
+ * Logarithmically normalized term frequency: {@code 1 + log(tf)} for terms
+ * that appear at least once, {@code 0.0} otherwise.
+ * <p>
+ * Dampens the effect of high term frequencies while still distinguishing
+ * between present and absent terms.
+ */
+ LOG_NORMALIZED_TF {
+ @Override
+ public double weight(int termFrequency, double inverseDocumentFrequency) {
+ return termFrequency > 0 ? 1.0 + Math.log(termFrequency) : 0.0;
+ }
+ };
+
+ /**
+ * Computes the feature weight for a term.
+ *
+ * @param termFrequency The number of occurrences of the term in the document.
+ * @param inverseDocumentFrequency The IDF value for the term (only used by {@link #TF_IDF}).
+ * @return The computed feature weight.
+ */
+ public abstract double weight(int termFrequency, double inverseDocumentFrequency);
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmClassLabel.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmClassLabel.java
new file mode 100644
index 00000000..d5ce4fe8
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmClassLabel.java
@@ -0,0 +1,69 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.io.Serial;
+
+import de.hhn.mi.domain.SvmClassLabel;
+
+/**
+ * An {@link SvmClassLabel} implementation used internally for training and classification
+ * in the OpenNLP SVM document categorizer.
+ */
+class TrainingSvmClassLabel implements SvmClassLabel {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final double numeric;
+ private final String name;
+ private final double probability;
+
+ /**
+ * Instantiates a {@link TrainingSvmClassLabel}.
+ *
+ * @param numeric The numeric label value.
+ * @param name The category name.
+ * @param probability The probability estimate.
+ */
+ TrainingSvmClassLabel(double numeric, String name, double probability) {
+ this.numeric = numeric;
+ this.name = name;
+ this.probability = probability;
+ }
+
+ @Override
+ public double getNumeric() {
+ return numeric;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public double getProbability() {
+ return probability;
+ }
+
+ @Override
+ public int compareTo(SvmClassLabel other) {
+ return Double.compare(this.probability, other.getProbability());
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmDocument.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmDocument.java
new file mode 100644
index 00000000..151ec812
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/main/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmDocument.java
@@ -0,0 +1,74 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.io.Serial;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import de.hhn.mi.domain.SvmClassLabel;
+import de.hhn.mi.domain.SvmDocument;
+import de.hhn.mi.domain.SvmFeature;
+
+/**
+ * An {@link SvmDocument} implementation used internally for training and classification
+ * in the OpenNLP SVM document categorizer.
+ */
+class TrainingSvmDocument implements SvmDocument {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final ArrayList<SvmFeature> features;
+ private final ArrayList<SvmClassLabel> classLabels = new ArrayList<>();
+
+ /**
+ * Instantiates a {@link TrainingSvmDocument} with the given features.
+ *
+ * @param features The list of {@link SvmFeature features}. Must not be {@code null}.
+ */
+ TrainingSvmDocument(List<SvmFeature> features) {
+ this.features = new ArrayList<>(Objects.requireNonNull(features, "features must not be null"));
+ }
+
+ @Override
+ public List<SvmFeature> getSvmFeatures() {
+ return features;
+ }
+
+ @Override
+ public SvmClassLabel getClassLabelWithHighestProbability() {
+ if (classLabels.isEmpty()) {
+ return null;
+ }
+ return Collections.max(classLabels);
+ }
+
+ @Override
+ public List<SvmClassLabel> getAllClassLabels() {
+ return Collections.unmodifiableList(classLabels);
+ }
+
+ @Override
+ public void addClassLabel(SvmClassLabel classLabel) {
+ Objects.requireNonNull(classLabel, "classLabel must not be null");
+ this.classLabels.add(classLabel);
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVMEdgeCaseTest.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVMEdgeCaseTest.java
new file mode 100644
index 00000000..4fbdb2b0
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVMEdgeCaseTest.java
@@ -0,0 +1,263 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import de.hhn.mi.configuration.KernelType;
+import de.hhn.mi.configuration.SvmConfigurationImpl;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.doccat.DocumentCategorizer;
+import opennlp.tools.doccat.DocumentSample;
+import opennlp.tools.doccat.FeatureGenerator;
+import opennlp.tools.util.ObjectStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DocumentCategorizerSVMEdgeCaseTest {
+
+ private static final SvmDoccatConfiguration LINEAR_BINARY =
+ new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setScaleFeatures(false)
+ .build();
+
+ private static final FeatureGenerator BOW = (text, extra) -> {
+ List<String> f = new ArrayList<>(text.length);
+ for (String w : text) f.add("bow=" + w);
+ return f;
+ };
+
+ // --- Null/empty argument validation ---
+
+ @Test
+ void testTrainNullSamplesThrows() {
+ assertThrows(NullPointerException.class, () ->
+ DocumentCategorizerSVM.train("eng", null, BOW));
+ }
+
+ @Test
+ void testTrainNullFeatureGeneratorsThrows() {
+ assertThrows(NullPointerException.class, () ->
+ DocumentCategorizerSVM.train("eng", emptyStream(), (FeatureGenerator[]) null));
+ }
+
+ @Test
+ void testTrainEmptyFeatureGeneratorsThrows() {
+ assertThrows(IllegalArgumentException.class, () ->
+ DocumentCategorizerSVM.train("eng", emptyStream(), new FeatureGenerator[0]));
+ }
+
+ @Test
+ void testTrainNullConfigThrows() {
+ assertThrows(NullPointerException.class, () ->
+ DocumentCategorizerSVM.train("eng", emptyStream(), (SvmDoccatConfiguration) null, BOW));
+ }
+
+ @Test
+ void testConstructorNullModelThrows() {
+ assertThrows(NullPointerException.class, () ->
+ new DocumentCategorizerSVM(null, BOW));
+ }
+
+ @Test
+ void testConstructorNullFeatureGeneratorsThrows() {
+ assertThrows(NullPointerException.class, () -> {
+ SvmDoccatModel model = trainMinimalModel();
+ new DocumentCategorizerSVM(model, (FeatureGenerator[]) null);
+ });
+ }
+
+ @Test
+ void testConstructorEmptyFeatureGeneratorsThrows() {
+ assertThrows(IllegalArgumentException.class, () -> {
+ SvmDoccatModel model = trainMinimalModel();
+ new DocumentCategorizerSVM(model, new FeatureGenerator[0]);
+ });
+ }
+
+ // --- Classification with unknown features ---
+
+ @Test
+ void testClassifyWithUnknownTokens() throws IOException {
+ SvmDoccatModel model = trainMinimalModel();
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BOW);
+
+ // Tokens not seen during training
+ double[] outcomes = cat.categorize(new String[]{"unknown1", "unknown2", "unknown3"});
+ assertNotNull(outcomes);
+ assertEquals(2, outcomes.length);
+ // Should still produce valid probabilities
+ String best = cat.getBestCategory(outcomes);
+ assertNotNull(best);
+ assertTrue("a".equals(best) || "b".equals(best));
+ }
+
+ @Test
+ void testClassifyWithMixOfKnownAndUnknown() throws IOException {
+ SvmDoccatModel model = trainMinimalModel();
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BOW);
+
+ // Mix of known ("x") and unknown ("zzz")
+ double[] outcomes = cat.categorize(new String[]{"x", "zzz"});
+ assertNotNull(outcomes);
+ assertEquals(2, outcomes.length);
+ }
+
+ @Test
+ void testClassifyWithExtraInformation() throws IOException {
+ SvmDoccatModel model = trainMinimalModel();
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BOW);
+
+ // Extra information is ignored by BagOfWords but shouldn't break anything
+ double[] outcomes = cat.categorize(new String[]{"x", "y"},
+ Map.of("key", "value"));
+ assertNotNull(outcomes);
+ assertEquals(2, outcomes.length);
+ }
+
+ // --- Multiple feature generators ---
+
+ @Test
+ void testMultipleFeatureGenerators() throws IOException {
+ FeatureGenerator bow = (text, extra) -> {
+ List<String> f = new ArrayList<>();
+ for (String w : text) f.add("bow=" + w);
+ return f;
+ };
+ FeatureGenerator bigram = (text, extra) -> {
+ List<String> f = new ArrayList<>();
+ for (int i = 0; i < text.length - 1; i++) {
+ f.add("bg=" + text[i] + "_" + text[i + 1]);
+ }
+ return f;
+ };
+
+ List<DocumentSample> samples = List.of(
+ new DocumentSample("a", new String[]{"x", "y", "z"}),
+ new DocumentSample("a", new String[]{"x", "y", "w"}),
+ new DocumentSample("b", new String[]{"p", "q", "r"}),
+ new DocumentSample("b", new String[]{"p", "q", "s"}));
+
+ SvmDoccatModel model = DocumentCategorizerSVM.train("eng",
+ new CollectionObjectStream<>(samples), LINEAR_BINARY, bow, bigram);
+
+ // Vocabulary should contain both bow= and bg= prefixed features
+ boolean hasBow = model.getFeatureVocabulary().keySet().stream()
+ .anyMatch(k -> k.startsWith("bow="));
+ boolean hasBigram = model.getFeatureVocabulary().keySet().stream()
+ .anyMatch(k -> k.startsWith("bg="));
+ assertTrue(hasBow, "Should have bag-of-words features");
+ assertTrue(hasBigram, "Should have bigram features");
+
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, bow, bigram);
+ assertNotNull(cat.categorize(new String[]{"x", "y"}));
+ }
+
+ // --- Three or more categories ---
+
+ @Test
+ void testThreeCategories() throws IOException {
+ List<DocumentSample> samples = new ArrayList<>();
+ samples.add(new DocumentSample("red", new String[]{"apple", "tomato", "cherry"}));
+ samples.add(new DocumentSample("red", new String[]{"apple", "strawberry", "cherry"}));
+ samples.add(new DocumentSample("green", new String[]{"lime", "leaf", "grass"}));
+ samples.add(new DocumentSample("green", new String[]{"lime", "mint", "grass"}));
+ samples.add(new DocumentSample("blue", new String[]{"sky", "ocean", "sapphire"}));
+ samples.add(new DocumentSample("blue", new String[]{"sky", "sea", "sapphire"}));
+
+ SvmDoccatModel model = DocumentCategorizerSVM.train("eng",
+ new CollectionObjectStream<>(samples), LINEAR_BINARY, BOW);
+
+ assertEquals(3, model.getNumberOfCategories());
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BOW);
+ double[] outcomes = cat.categorize(new String[]{"sky", "ocean", "sapphire"});
+ assertEquals(3, outcomes.length);
+
+ // All probabilities should be non-negative and sum to ~1
+ double sum = 0;
+ for (double p : outcomes) {
+ assertTrue(p >= 0.0);
+ sum += p;
+ }
+ assertTrue(Math.abs(sum - 1.0) < 0.05);
+ }
+
+ // --- GetIndex for non-existent category ---
+
+ @Test
+ void testGetIndexNonExistent() throws IOException {
+ SvmDoccatModel model = trainMinimalModel();
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BOW);
+ assertEquals(-1, cat.getIndex("nonexistent_category"));
+ }
+
+ // --- GetAllResults format ---
+
+ @Test
+ void testGetAllResultsFormat() throws IOException {
+ SvmDoccatModel model = trainMinimalModel();
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BOW);
+ double[] outcomes = cat.categorize(new String[]{"x", "y"});
+
+ String all = cat.getAllResults(outcomes);
+ // Should contain category names and bracketed scores
+ assertTrue(all.contains("["));
+ assertTrue(all.contains("]"));
+ for (int i = 0; i < cat.getNumberOfCategories(); i++) {
+ assertTrue(all.contains(cat.getCategory(i)));
+ }
+ }
+
+ // --- Helpers ---
+
+ private SvmDoccatModel trainMinimalModel() throws IOException {
+ List<DocumentSample> samples = List.of(
+ new DocumentSample("a", new String[]{"x", "y"}),
+ new DocumentSample("a", new String[]{"x", "z"}),
+ new DocumentSample("b", new String[]{"p", "q"}),
+ new DocumentSample("b", new String[]{"p", "r"}));
+
+ return DocumentCategorizerSVM.train("eng",
+ new CollectionObjectStream<>(samples), LINEAR_BINARY, BOW);
+ }
+
+ private ObjectStream<DocumentSample> emptyStream() {
+ return new CollectionObjectStream<>(List.of());
+ }
+
+ static class CollectionObjectStream<T> implements ObjectStream<T> {
+ private final List<T> items;
+ private int index = 0;
+ CollectionObjectStream(List<T> items) { this.items = items; }
+ @Override public T read() { return index < items.size() ? items.get(index++) : null; }
+ @Override public void reset() { index = 0; }
+ @Override public void close() { }
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVMTest.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVMTest.java
new file mode 100644
index 00000000..c73eea8d
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/DocumentCategorizerSVMTest.java
@@ -0,0 +1,527 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import de.hhn.mi.configuration.KernelType;
+import de.hhn.mi.configuration.SvmConfigurationImpl;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import opennlp.tools.doccat.DocumentCategorizer;
+import opennlp.tools.doccat.DocumentSample;
+import opennlp.tools.doccat.FeatureGenerator;
+import opennlp.tools.util.ObjectStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DocumentCategorizerSVMTest {
+
+ private static final FeatureGenerator BAG_OF_WORDS = new BowFeatureGenerator();
+
+ private static List<DocumentSample> trainingSamples;
+
+ private static final String[] SPORTS_QUERY =
+ {"football", "goal", "team", "match", "player", "game", "stadium", "coach"};
+ private static final String[] TECH_QUERY =
+ {"computer", "software", "code", "algorithm", "system", "developer", "api", "database"};
+
+ /**
+ * A robust configuration: LINEAR kernel + BINARY weighting — the textbook
+ * combination for SVM text classification on small datasets.
+ */
+ private static final SvmDoccatConfiguration ROBUST_CONFIG =
+ new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setScaleFeatures(false)
+ .build();
+
+ @BeforeAll
+ static void setUp() {
+ trainingSamples = new ArrayList<>();
+ // Sports samples
+ trainingSamples.add(new DocumentSample("sports",
+ new String[]{"football", "goal", "team", "match", "player", "stadium"}));
+ trainingSamples.add(new DocumentSample("sports",
+ new String[]{"basketball", "score", "team", "court", "player", "game"}));
+ trainingSamples.add(new DocumentSample("sports",
+ new String[]{"tennis", "match", "serve", "court", "player", "game"}));
+ trainingSamples.add(new DocumentSample("sports",
+ new String[]{"football", "match", "goal", "referee", "stadium", "game"}));
+ trainingSamples.add(new DocumentSample("sports",
+ new String[]{"basketball", "team", "score", "ball", "arena", "game"}));
+ trainingSamples.add(new DocumentSample("sports",
+ new String[]{"football", "goal", "player", "match", "game", "coach"}));
+ trainingSamples.add(new DocumentSample("sports",
+ new String[]{"tennis", "serve", "player", "court", "match", "game"}));
+ trainingSamples.add(new DocumentSample("sports",
+ new String[]{"basketball", "team", "court", "player", "score", "game"}));
+
+ // Technology samples
+ trainingSamples.add(new DocumentSample("technology",
+ new String[]{"computer", "software", "code", "program", "algorithm", "system"}));
+ trainingSamples.add(new DocumentSample("technology",
+ new String[]{"internet", "network", "server", "cloud", "data", "system"}));
+ trainingSamples.add(new DocumentSample("technology",
+ new String[]{"programming", "software", "developer", "code", "api", "system"}));
+ trainingSamples.add(new DocumentSample("technology",
+ new String[]{"computer", "algorithm", "data", "machine", "learning", "system"}));
+ trainingSamples.add(new DocumentSample("technology",
+ new String[]{"software", "code", "program", "developer", "debug", "system"}));
+ trainingSamples.add(new DocumentSample("technology",
+ new String[]{"computer", "software", "algorithm", "code", "system", "database"}));
+ trainingSamples.add(new DocumentSample("technology",
+ new String[]{"network", "server", "data", "cloud", "system", "api"}));
+ trainingSamples.add(new DocumentSample("technology",
+ new String[]{"programming", "code", "developer", "software", "system", "debug"}));
+ }
+
+ private static SvmDoccatModel trainModel(SvmDoccatConfiguration config) throws IOException {
+ return DocumentCategorizerSVM.train("eng",
+ new CollectionObjectStream<>(trainingSamples), config, BAG_OF_WORDS);
+ }
+
+ private static void assertClassifiesCorrectly(DocumentCategorizer cat) {
+ assertEquals("sports", cat.getBestCategory(cat.categorize(SPORTS_QUERY)));
+ assertEquals("technology", cat.getBestCategory(cat.categorize(TECH_QUERY)));
+ }
+
+ private static void assertValidProbabilities(double[] outcomes, int expectedCategories) {
+ assertNotNull(outcomes);
+ assertEquals(expectedCategories, outcomes.length);
+ double sum = 0;
+ for (double p : outcomes) {
+ assertTrue(p >= 0.0, "Probabilities must be non-negative");
+ sum += p;
+ }
+ assertTrue(Math.abs(sum - 1.0) < 0.05, "Probabilities should sum to ~1.0");
+ }
+
+ // --- Robust configuration: LINEAR + BINARY ---
+
+ @Test
+ void testLinearBinaryClassifiesCorrectly() throws IOException {
+ SvmDoccatModel model = trainModel(ROBUST_CONFIG);
+ assertNotNull(model);
+ assertEquals(2, model.getNumberOfCategories());
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ // --- Default configuration ---
+
+ @Test
+ void testTrainAndClassifyDefaults() throws IOException {
+ SvmDoccatModel model = DocumentCategorizerSVM.train("eng",
+ new CollectionObjectStream<>(trainingSamples), BAG_OF_WORDS);
+ assertNotNull(model);
+ assertEquals(2, model.getNumberOfCategories());
+
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ assertValidProbabilities(cat.categorize(SPORTS_QUERY), 2);
+ assertValidProbabilities(cat.categorize(TECH_QUERY), 2);
+ }
+
+ // --- Term weighting strategies ---
+
+ @ParameterizedTest
+ @EnumSource(TermWeightingStrategy.class)
+ void testTermWeightingStrategiesProduceValidOutput(TermWeightingStrategy strategy) throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(strategy)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertNotNull(model);
+
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ assertValidProbabilities(cat.categorize(SPORTS_QUERY), 2);
+ assertValidProbabilities(cat.categorize(TECH_QUERY), 2);
+
+ // Both categories must be different outcomes
+ String sportsCat = cat.getBestCategory(cat.categorize(SPORTS_QUERY));
+ String techCat = cat.getBestCategory(cat.categorize(TECH_QUERY));
+ assertTrue("sports".equals(sportsCat) || "technology".equals(sportsCat));
+ assertTrue("sports".equals(techCat) || "technology".equals(techCat));
+ }
+
+ @Test
+ void testBinaryWeightingClassifiesCorrectly() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ @Test
+ void testTermFrequencyWeightingClassifiesCorrectly() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.TERM_FREQUENCY)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ // --- Feature selection strategies ---
+
+ @ParameterizedTest
+ @EnumSource(value = FeatureSelectionStrategy.class, names = "NONE", mode = EnumSource.Mode.EXCLUDE)
+ void testFeatureSelectionStrategiesLimitVocabulary(FeatureSelectionStrategy strategy) throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setFeatureSelectionStrategy(strategy)
+ .setMaxFeatures(12)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertNotNull(model);
+ assertTrue(model.getFeatureVocabulary().size() <= 12);
+
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ assertValidProbabilities(cat.categorize(TECH_QUERY), 2);
+ }
+
+ @Test
+ void testInformationGainSelection() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setFeatureSelectionStrategy(FeatureSelectionStrategy.INFORMATION_GAIN)
+ .setMaxFeatures(15)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertTrue(model.getFeatureVocabulary().size() <= 15);
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ @Test
+ void testChiSquareSelection() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setFeatureSelectionStrategy(FeatureSelectionStrategy.CHI_SQUARE)
+ .setMaxFeatures(15)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertTrue(model.getFeatureVocabulary().size() <= 15);
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ @Test
+ void testTermFrequencySelection() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setFeatureSelectionStrategy(FeatureSelectionStrategy.TERM_FREQUENCY)
+ .setMaxFeatures(15)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertTrue(model.getFeatureVocabulary().size() <= 15);
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ assertValidProbabilities(cat.categorize(SPORTS_QUERY), 2);
+ }
+
+ @Test
+ void testDocumentFrequencySelection() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setFeatureSelectionStrategy(FeatureSelectionStrategy.DOCUMENT_FREQUENCY)
+ .setMaxFeatures(15)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertTrue(model.getFeatureVocabulary().size() <= 15);
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ assertValidProbabilities(cat.categorize(TECH_QUERY), 2);
+ }
+
+ // --- Feature scaling ---
+
+ @Test
+ void testWithScaling() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setScaleFeatures(true)
+ .setScaleRange(0.0, 1.0)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ @Test
+ void testCustomScaleRange() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setScaleFeatures(true)
+ .setScaleRange(-1.0, 1.0)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ // --- SVM configuration ---
+
+ @Test
+ void testRBFKernel() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.RBF)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ assertValidProbabilities(cat.categorize(TECH_QUERY), 2);
+ }
+
+ @Test
+ void testCustomCostParameter() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setCost(10.0)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setScaleFeatures(false)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ // --- Combined configuration ---
+
+ @Test
+ void testFullPipeline() throws IOException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setCost(1.0)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setFeatureSelectionStrategy(FeatureSelectionStrategy.INFORMATION_GAIN)
+ .setMaxFeatures(20)
+ .setScaleFeatures(true)
+ .setScaleRange(0.0, 1.0)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+ assertNotNull(model);
+ assertTrue(model.getFeatureVocabulary().size() <= 20);
+ assertClassifiesCorrectly(new DocumentCategorizerSVM(model, BAG_OF_WORDS));
+ }
+
+ // --- DocumentCategorizer API ---
+
+ @Test
+ void testGetCategoryAndIndex() throws IOException {
+ SvmDoccatModel model = trainModel(ROBUST_CONFIG);
+
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ int sportsIdx = cat.getIndex("sports");
+ int techIdx = cat.getIndex("technology");
+ assertTrue(sportsIdx >= 0);
+ assertTrue(techIdx >= 0);
+ assertTrue(sportsIdx != techIdx);
+ assertEquals("sports", cat.getCategory(sportsIdx));
+ assertEquals("technology", cat.getCategory(techIdx));
+ assertEquals(-1, cat.getIndex("nonexistent"));
+ }
+
+ @Test
+ void testScoreMap() throws IOException {
+ SvmDoccatModel model = trainModel(ROBUST_CONFIG);
+
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ Map<String, Double> scores = cat.scoreMap(SPORTS_QUERY);
+ assertEquals(2, scores.size());
+ assertTrue(scores.containsKey("sports"));
+ assertTrue(scores.containsKey("technology"));
+ assertTrue(scores.get("sports") > scores.get("technology"),
+ "Sports score should be higher for sports query");
+ }
+
+ @Test
+ void testSortedScoreMap() throws IOException {
+ SvmDoccatModel model = trainModel(ROBUST_CONFIG);
+
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ var sorted = cat.sortedScoreMap(TECH_QUERY);
+ assertNotNull(sorted);
+ assertTrue(sorted.size() >= 1);
+ }
+
+ @Test
+ void testGetAllResults() throws IOException {
+ SvmDoccatModel model = trainModel(ROBUST_CONFIG);
+
+ DocumentCategorizer cat = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ double[] outcomes = cat.categorize(SPORTS_QUERY);
+ String allResults = cat.getAllResults(outcomes);
+ assertNotNull(allResults);
+ assertTrue(allResults.contains("sports"));
+ assertTrue(allResults.contains("technology"));
+ }
+
+ // --- Model serialization ---
+
+ @Test
+ void testModelSerialization() throws IOException, ClassNotFoundException {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setFeatureSelectionStrategy(FeatureSelectionStrategy.INFORMATION_GAIN)
+ .setMaxFeatures(20)
+ .setScaleFeatures(true)
+ .build();
+
+ SvmDoccatModel model = trainModel(config);
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ model.serialize(baos);
+ byte[] bytes = baos.toByteArray();
+ assertTrue(bytes.length > 0);
+
+ SvmDoccatModel deserialized = SvmDoccatModel.deserialize(new ByteArrayInputStream(bytes));
+ assertNotNull(deserialized);
+ assertEquals(model.getNumberOfCategories(), deserialized.getNumberOfCategories());
+ assertEquals(model.getLanguageCode(), deserialized.getLanguageCode());
+ assertEquals(model.getFeatureVocabulary().size(), deserialized.getFeatureVocabulary().size());
+
+ // Verify deserialized model classifies identically
+ DocumentCategorizer original = new DocumentCategorizerSVM(model, BAG_OF_WORDS);
+ DocumentCategorizer restored = new DocumentCategorizerSVM(deserialized, BAG_OF_WORDS);
+
+ assertClassifiesCorrectly(original);
+ assertClassifiesCorrectly(restored);
+ }
+
+ // --- Helper classes ---
+
+ static class BowFeatureGenerator implements FeatureGenerator {
+ @Override
+ public Collection<String> extractFeatures(String[] text, Map<String, Object> extraInformation) {
+ List<String> features = new ArrayList<>(text.length);
+ for (String word : text) {
+ features.add("bow=" + word);
+ }
+ return features;
+ }
+ }
+
+ static class CollectionObjectStream<T> implements ObjectStream<T> {
+ private final List<T> items;
+ private int index = 0;
+
+ CollectionObjectStream(List<T> items) {
+ this.items = items;
+ }
+
+ @Override
+ public T read() {
+ if (index < items.size()) {
+ return items.get(index++);
+ }
+ return null;
+ }
+
+ @Override
+ public void reset() {
+ index = 0;
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/FeatureSelectionTest.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/FeatureSelectionTest.java
new file mode 100644
index 00000000..59d32c54
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/FeatureSelectionTest.java
@@ -0,0 +1,171 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FeatureSelectionTest {
+
+ private static List<Set<String>> docFeatureSets;
+ private static List<Map<String, Integer>> docTfMaps;
+ private static List<String> docLabels;
+
+ @BeforeAll
+ static void setUp() {
+ // Doc 0: sports - "goal team match"
+ // Doc 1: sports - "goal player score"
+ // Doc 2: tech - "code software algorithm"
+ // Doc 3: tech - "code data server"
+ docFeatureSets = new ArrayList<>();
+ docTfMaps = new ArrayList<>();
+ docLabels = new ArrayList<>();
+
+ addDoc(Map.of("goal", 2, "team", 1, "match", 1), "sports");
+ addDoc(Map.of("goal", 1, "player", 1, "score", 1), "sports");
+ addDoc(Map.of("code", 2, "software", 1, "algorithm", 1), "technology");
+ addDoc(Map.of("code", 1, "data", 1, "server", 1), "technology");
+ }
+
+ private static void addDoc(Map<String, Integer> tf, String label) {
+ docTfMaps.add(tf);
+ docFeatureSets.add(tf.keySet());
+ docLabels.add(label);
+ }
+
+ @Test
+ void testInformationGainScores() {
+ Map<String, Double> scores = FeatureSelection.computeInformationGain(
+ docFeatureSets, docLabels);
+ assertNotNull(scores);
+ assertFalse(scores.isEmpty());
+
+ // "goal" only in sports, "code" only in tech -> high IG
+ // "team" only in one doc -> less discriminative overall
+ assertTrue(scores.get("goal") > 0);
+ assertTrue(scores.get("code") > 0);
+ }
+
+ @Test
+ void testChiSquareScores() {
+ Map<String, Double> scores = FeatureSelection.computeChiSquare(
+ docFeatureSets, docLabels);
+ assertNotNull(scores);
+ assertFalse(scores.isEmpty());
+
+ // Features exclusive to one class should have high chi-square
+ assertTrue(scores.get("goal") > 0);
+ assertTrue(scores.get("code") > 0);
+ }
+
+ @Test
+ void testTermFrequencyScores() {
+ Map<String, Double> scores = FeatureSelection.computeTermFrequency(docTfMaps);
+ assertNotNull(scores);
+
+ // "goal" appears 3 times total (2+1), "code" appears 3 times (2+1)
+ assertEquals(3.0, scores.get("goal"));
+ assertEquals(3.0, scores.get("code"));
+ assertEquals(1.0, scores.get("team"));
+ }
+
+ @Test
+ void testDocumentFrequencyScores() {
+ Map<String, Double> scores = FeatureSelection.computeDocumentFrequency(docFeatureSets);
+ assertNotNull(scores);
+
+ // "goal" in 2 docs, "code" in 2 docs, "team" in 1 doc
+ assertEquals(2.0, scores.get("goal"));
+ assertEquals(2.0, scores.get("code"));
+ assertEquals(1.0, scores.get("team"));
+ }
+
+ @Test
+ void testSelectTopFeaturesByInformationGain() {
+ Set<String> selected = FeatureSelection.selectTopFeatures(
+ docFeatureSets, docTfMaps, docLabels,
+ FeatureSelectionStrategy.INFORMATION_GAIN, 3);
+
+ assertNotNull(selected);
+ assertEquals(3, selected.size());
+ }
+
+ @Test
+ void testSelectTopFeaturesByChiSquare() {
+ Set<String> selected = FeatureSelection.selectTopFeatures(
+ docFeatureSets, docTfMaps, docLabels,
+ FeatureSelectionStrategy.CHI_SQUARE, 3);
+
+ assertNotNull(selected);
+ assertEquals(3, selected.size());
+ }
+
+ @Test
+ void testSelectTopFeaturesByTermFrequency() {
+ Set<String> selected = FeatureSelection.selectTopFeatures(
+ docFeatureSets, docTfMaps, docLabels,
+ FeatureSelectionStrategy.TERM_FREQUENCY, 2);
+
+ assertNotNull(selected);
+ assertEquals(2, selected.size());
+ // "goal" and "code" both have tf=3, so they should be in the top 2
+ assertTrue(selected.contains("goal"));
+ assertTrue(selected.contains("code"));
+ }
+
+ @Test
+ void testSelectTopFeaturesByDocumentFrequency() {
+ Set<String> selected = FeatureSelection.selectTopFeatures(
+ docFeatureSets, docTfMaps, docLabels,
+ FeatureSelectionStrategy.DOCUMENT_FREQUENCY, 2);
+
+ assertNotNull(selected);
+ assertEquals(2, selected.size());
+ assertTrue(selected.contains("goal"));
+ assertTrue(selected.contains("code"));
+ }
+
+ @Test
+ void testSelectAllWhenMaxIsNegative() {
+ Set<String> selected = FeatureSelection.selectTopFeatures(
+ docFeatureSets, docTfMaps, docLabels,
+ FeatureSelectionStrategy.INFORMATION_GAIN, -1);
+
+ // All 8 unique features
+ assertEquals(10, selected.size());
+ }
+
+ @Test
+ void testSelectAllWhenMaxExceedsTotalFeatures() {
+ Set<String> selected = FeatureSelection.selectTopFeatures(
+ docFeatureSets, docTfMaps, docLabels,
+ FeatureSelectionStrategy.TERM_FREQUENCY, 100);
+
+ assertEquals(10, selected.size());
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatConfigurationTest.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatConfigurationTest.java
new file mode 100644
index 00000000..f59b1812
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatConfigurationTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import de.hhn.mi.configuration.KernelType;
+import de.hhn.mi.configuration.SvmConfigurationImpl;
+import de.hhn.mi.configuration.SvmType;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SvmDoccatConfigurationTest {
+
+ @Test
+ void testDefaults() {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder().build();
+
+ assertNotNull(config.getSvmConfiguration());
+ assertEquals(TermWeightingStrategy.TF_IDF, config.getTermWeightingStrategy());
+ assertEquals(FeatureSelectionStrategy.NONE, config.getFeatureSelectionStrategy());
+ assertEquals(-1, config.getMaxFeatures());
+ assertTrue(config.isScaleFeatures());
+ assertEquals(0.0, config.getScaleLower());
+ assertEquals(1.0, config.getScaleUpper());
+ }
+
+ @Test
+ void testCustomSvmConfiguration() {
+ var svmConfig = new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.POLYNOMIAL)
+ .setCost(5.0)
+ .setDegree(2)
+ .setProbability(true)
+ .build();
+
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(svmConfig)
+ .build();
+
+ assertEquals(KernelType.POLYNOMIAL, config.getSvmConfiguration().getKernelType());
+ assertEquals(5.0, config.getSvmConfiguration().getCost());
+ assertEquals(2, config.getSvmConfiguration().getDegree());
+ }
+
+ @Test
+ void testAllWeightingStrategies() {
+ for (TermWeightingStrategy strategy : TermWeightingStrategy.values()) {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setTermWeightingStrategy(strategy)
+ .build();
+ assertEquals(strategy, config.getTermWeightingStrategy());
+ }
+ }
+
+ @Test
+ void testAllFeatureSelectionStrategies() {
+ for (FeatureSelectionStrategy strategy : FeatureSelectionStrategy.values()) {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setFeatureSelectionStrategy(strategy)
+ .build();
+ assertEquals(strategy, config.getFeatureSelectionStrategy());
+ }
+ }
+
+ @Test
+ void testMaxFeatures() {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setMaxFeatures(100)
+ .build();
+ assertEquals(100, config.getMaxFeatures());
+ }
+
+ @Test
+ void testScalingDisabled() {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setScaleFeatures(false)
+ .build();
+ assertFalse(config.isScaleFeatures());
+ }
+
+ @Test
+ void testCustomScaleRange() {
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setScaleRange(-1.0, 1.0)
+ .build();
+ assertEquals(-1.0, config.getScaleLower());
+ assertEquals(1.0, config.getScaleUpper());
+ }
+
+ @Test
+ void testInvalidScaleRangeThrows() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new SvmDoccatConfiguration.Builder().setScaleRange(1.0, 0.0));
+ }
+
+ @Test
+ void testEqualBoundsThrows() {
+ assertThrows(IllegalArgumentException.class, () ->
+ new SvmDoccatConfiguration.Builder().setScaleRange(0.5, 0.5));
+ }
+
+ @Test
+ void testNullSvmConfigurationThrows() {
+ assertThrows(NullPointerException.class, () ->
+ new SvmDoccatConfiguration.Builder().setSvmConfiguration(null));
+ }
+
+ @Test
+ void testNullWeightingStrategyThrows() {
+ assertThrows(NullPointerException.class, () ->
+ new SvmDoccatConfiguration.Builder().setTermWeightingStrategy(null));
+ }
+
+ @Test
+ void testNullFeatureSelectionStrategyThrows() {
+ assertThrows(NullPointerException.class, () ->
+ new SvmDoccatConfiguration.Builder().setFeatureSelectionStrategy(null));
+ }
+
+ @Test
+ void testSvmTypePreserved() {
+ var svmConfig = new SvmConfigurationImpl.Builder()
+ .setSvmType(SvmType.NU_SVC)
+ .setProbability(true)
+ .build();
+
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(svmConfig)
+ .build();
+
+ assertEquals(SvmType.NU_SVC, config.getSvmConfiguration().getSvmType());
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatModelTest.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatModelTest.java
new file mode 100644
index 00000000..b7108a8b
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/SvmDoccatModelTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import de.hhn.mi.configuration.KernelType;
+import de.hhn.mi.configuration.SvmConfigurationImpl;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.doccat.DocumentSample;
+import opennlp.tools.doccat.FeatureGenerator;
+import opennlp.tools.util.ObjectStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SvmDoccatModelTest {
+
+ private static final FeatureGenerator BOW = (text, extra) -> {
+ List<String> f = new ArrayList<>(text.length);
+ for (String w : text) f.add("bow=" + w);
+ return f;
+ };
+
+ @Test
+ void testModelPropertiesAfterTraining() throws IOException {
+ SvmDoccatModel model = trainSimpleModel();
+
+ assertEquals("eng", model.getLanguageCode());
+ assertEquals(2, model.getNumberOfCategories());
+ assertNotNull(model.getSvmModel());
+ assertNotNull(model.getConfiguration());
+ assertTrue(model.getFeatureVocabulary().size() > 0);
+ assertTrue(model.getIdfValues().size() > 0);
+ }
+
+ @Test
+ void testVocabularyIsUnmodifiable() throws IOException {
+ SvmDoccatModel model = trainSimpleModel();
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ model.getFeatureVocabulary().put("new", 999));
+ }
+
+ @Test
+ void testCategoryMapsAreUnmodifiable() throws IOException {
+ SvmDoccatModel model = trainSimpleModel();
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ model.getIndexToCategory().put(999, "new"));
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ model.getCategoryToIndex().put("new", 999));
+ }
+
+ @Test
+ void testIdfValuesAreUnmodifiable() throws IOException {
+ SvmDoccatModel model = trainSimpleModel();
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ model.getIdfValues().put("new", 1.0));
+ }
+
+ @Test
+ void testFeatureMinMaxAreUnmodifiable() throws IOException {
+ SvmDoccatModel model = trainSimpleModel();
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ model.getFeatureMinValues().put(999, 0.0));
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ model.getFeatureMaxValues().put(999, 1.0));
+ }
+
+ @Test
+ void testCategoryMappingConsistency() throws IOException {
+ SvmDoccatModel model = trainSimpleModel();
+
+ for (Map.Entry<String, Integer> entry : model.getCategoryToIndex().entrySet()) {
+ assertEquals(entry.getKey(), model.getIndexToCategory().get(entry.getValue()));
+ }
+ }
+
+ @Test
+ void testSerializeAndDeserialize() throws IOException, ClassNotFoundException {
+ SvmDoccatModel model = trainSimpleModel();
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ model.serialize(baos);
+ byte[] bytes = baos.toByteArray();
+ assertTrue(bytes.length > 0);
+
+ SvmDoccatModel restored = SvmDoccatModel.deserialize(new ByteArrayInputStream(bytes));
+
+ assertEquals(model.getLanguageCode(), restored.getLanguageCode());
+ assertEquals(model.getNumberOfCategories(), restored.getNumberOfCategories());
+ assertEquals(model.getFeatureVocabulary(), restored.getFeatureVocabulary());
+ assertEquals(model.getIndexToCategory(), restored.getIndexToCategory());
+ assertEquals(model.getCategoryToIndex(), restored.getCategoryToIndex());
+ assertEquals(model.getIdfValues(), restored.getIdfValues());
+ assertEquals(model.getFeatureMinValues(), restored.getFeatureMinValues());
+ assertEquals(model.getFeatureMaxValues(), restored.getFeatureMaxValues());
+ }
+
+ @Test
+ void testDeserializeInvalidStreamThrows() {
+ byte[] garbage = {0x00, 0x01, 0x02};
+ assertThrows(IOException.class, () ->
+ SvmDoccatModel.deserialize(new ByteArrayInputStream(garbage)));
+ }
+
+ private SvmDoccatModel trainSimpleModel() throws IOException {
+ List<DocumentSample> samples = new ArrayList<>();
+ samples.add(new DocumentSample("a", new String[]{"x", "y"}));
+ samples.add(new DocumentSample("a", new String[]{"x", "z"}));
+ samples.add(new DocumentSample("b", new String[]{"p", "q"}));
+ samples.add(new DocumentSample("b", new String[]{"p", "r"}));
+
+ SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.BINARY)
+ .setScaleFeatures(false)
+ .build();
+
+ return DocumentCategorizerSVM.train("eng",
+ new CollectionObjectStream<>(samples), config, BOW);
+ }
+
+ static class CollectionObjectStream<T> implements ObjectStream<T> {
+ private final List<T> items;
+ private int index = 0;
+ CollectionObjectStream(List<T> items) { this.items = items; }
+ @Override public T read() { return index < items.size() ? items.get(index++) : null; }
+ @Override public void reset() { index = 0; }
+ @Override public void close() { }
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TermWeightingStrategyTest.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TermWeightingStrategyTest.java
new file mode 100644
index 00000000..35fbccd3
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TermWeightingStrategyTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class TermWeightingStrategyTest {
+
+ @Test
+ void testBinary() {
+ assertEquals(1.0, TermWeightingStrategy.BINARY.weight(1, 0.0));
+ assertEquals(1.0, TermWeightingStrategy.BINARY.weight(5, 2.0));
+ assertEquals(0.0, TermWeightingStrategy.BINARY.weight(0, 0.0));
+ }
+
+ @Test
+ void testTermFrequency() {
+ assertEquals(3.0, TermWeightingStrategy.TERM_FREQUENCY.weight(3, 0.0));
+ assertEquals(0.0, TermWeightingStrategy.TERM_FREQUENCY.weight(0, 0.0));
+ assertEquals(1.0, TermWeightingStrategy.TERM_FREQUENCY.weight(1, 5.0));
+ }
+
+ @Test
+ void testTfIdf() {
+ double idf = Math.log(10.0 / 2.0); // ~1.609
+ assertEquals(3.0 * idf, TermWeightingStrategy.TF_IDF.weight(3, idf), 1e-9);
+ assertEquals(0.0, TermWeightingStrategy.TF_IDF.weight(0, idf));
+ }
+
+ @Test
+ void testLogNormalizedTf() {
+ assertEquals(1.0 + Math.log(3), TermWeightingStrategy.LOG_NORMALIZED_TF.weight(3, 0.0), 1e-9);
+ assertEquals(1.0, TermWeightingStrategy.LOG_NORMALIZED_TF.weight(1, 0.0));
+ assertEquals(0.0, TermWeightingStrategy.LOG_NORMALIZED_TF.weight(0, 0.0));
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmClassLabelTest.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmClassLabelTest.java
new file mode 100644
index 00000000..f045c82b
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmClassLabelTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TrainingSvmClassLabelTest {
+
+ @Test
+ void testGetters() {
+ TrainingSvmClassLabel label = new TrainingSvmClassLabel(2.0, "sports", 0.85);
+ assertEquals(2.0, label.getNumeric());
+ assertEquals("sports", label.getName());
+ assertEquals(0.85, label.getProbability());
+ }
+
+ @Test
+ void testCompareToHigherProbability() {
+ TrainingSvmClassLabel low = new TrainingSvmClassLabel(0, "a", 0.2);
+ TrainingSvmClassLabel high = new TrainingSvmClassLabel(1, "b", 0.8);
+
+ assertTrue(low.compareTo(high) < 0);
+ assertTrue(high.compareTo(low) > 0);
+ }
+
+ @Test
+ void testCompareToEqualProbability() {
+ TrainingSvmClassLabel a = new TrainingSvmClassLabel(0, "a", 0.5);
+ TrainingSvmClassLabel b = new TrainingSvmClassLabel(1, "b", 0.5);
+
+ assertEquals(0, a.compareTo(b));
+ }
+
+ @Test
+ void testCompareToZeroProbability() {
+ TrainingSvmClassLabel zero = new TrainingSvmClassLabel(0, "z", 0.0);
+ TrainingSvmClassLabel nonZero = new TrainingSvmClassLabel(1, "nz", 0.01);
+
+ assertTrue(zero.compareTo(nonZero) < 0);
+ }
+}
diff --git a/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmDocumentTest.java b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmDocumentTest.java
new file mode 100644
index 00000000..4adaa440
--- /dev/null
+++ b/opennlp-core/opennlp-ml/opennlp-ml-libsvm/src/test/java/opennlp/tools/ml/libsvm/doccat/TrainingSvmDocumentTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.ml.libsvm.doccat;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import de.hhn.mi.domain.SvmClassLabel;
+import de.hhn.mi.domain.SvmFeature;
+import de.hhn.mi.domain.SvmFeatureImpl;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TrainingSvmDocumentTest {
+
+ @Test
+ void testEmptyFeatures() {
+ TrainingSvmDocument doc = new TrainingSvmDocument(List.of());
+ assertTrue(doc.getSvmFeatures().isEmpty());
+ assertTrue(doc.getAllClassLabels().isEmpty());
+ assertNull(doc.getClassLabelWithHighestProbability());
+ }
+
+ @Test
+ void testFeaturesAreRetained() {
+ List<SvmFeature> features = new ArrayList<>();
+ features.add(new SvmFeatureImpl(1, 0.5));
+ features.add(new SvmFeatureImpl(3, 1.2));
+ TrainingSvmDocument doc = new TrainingSvmDocument(features);
+
+ assertEquals(2, doc.getSvmFeatures().size());
+ assertEquals(1, doc.getSvmFeatures().get(0).getIndex());
+ assertEquals(0.5, doc.getSvmFeatures().get(0).getValue());
+ assertEquals(3, doc.getSvmFeatures().get(1).getIndex());
+ }
+
+ @Test
+ void testAddAndRetrieveClassLabels() {
+ TrainingSvmDocument doc = new TrainingSvmDocument(List.of());
+
+ doc.addClassLabel(new TrainingSvmClassLabel(0, "cat_a", 0.3));
+ doc.addClassLabel(new TrainingSvmClassLabel(1, "cat_b", 0.7));
+
+ assertEquals(2, doc.getAllClassLabels().size());
+ SvmClassLabel best = doc.getClassLabelWithHighestProbability();
+ assertNotNull(best);
+ assertEquals(1.0, best.getNumeric());
+ assertEquals(0.7, best.getProbability());
+ }
+
+ @Test
+ void testGetClassLabelWithHighestProbability() {
+ TrainingSvmDocument doc = new TrainingSvmDocument(List.of());
+ doc.addClassLabel(new TrainingSvmClassLabel(0, "low", 0.1));
+ doc.addClassLabel(new TrainingSvmClassLabel(1, "mid", 0.5));
+ doc.addClassLabel(new TrainingSvmClassLabel(2, "high", 0.9));
+
+ assertEquals("high", doc.getClassLabelWithHighestProbability().getName());
+ }
+
+ @Test
+ void testAllClassLabelsIsUnmodifiable() {
+ TrainingSvmDocument doc = new TrainingSvmDocument(List.of());
+ doc.addClassLabel(new TrainingSvmClassLabel(0, "a", 1.0));
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ doc.getAllClassLabels().add(new TrainingSvmClassLabel(1, "b", 0.5)));
+ }
+
+ @Test
+ void testNullFeaturesThrows() {
+ assertThrows(NullPointerException.class, () -> new TrainingSvmDocument(null));
+ }
+
+ @Test
+ void testNullClassLabelThrows() {
+ TrainingSvmDocument doc = new TrainingSvmDocument(List.of());
+ assertThrows(NullPointerException.class, () -> doc.addClassLabel(null));
+ }
+}
diff --git a/opennlp-core/opennlp-ml/pom.xml b/opennlp-core/opennlp-ml/pom.xml
index 288f2a08..5449e4e9 100644
--- a/opennlp-core/opennlp-ml/pom.xml
+++ b/opennlp-core/opennlp-ml/pom.xml
@@ -36,6 +36,7 @@
<module>opennlp-ml-maxent</module>
<module>opennlp-ml-bayes</module>
<module>opennlp-ml-perceptron</module>
+ <module>opennlp-ml-libsvm</module>
<module>opennlp-dl</module>
<module>opennlp-dl-gpu</module>
</modules>
diff --git a/opennlp-docs/src/docbkx/doccat.xml b/opennlp-docs/src/docbkx/doccat.xml
index eb58e70a..7d03f1c2 100644
--- a/opennlp-docs/src/docbkx/doccat.xml
+++ b/opennlp-docs/src/docbkx/doccat.xml
@@ -26,8 +26,10 @@ under the License.
<section xml:id="tools.doccat.classifying">
<title>Classifying</title>
<para>
- The OpenNLP Document Categorizer can classify text into pre-defined categories.
- It is based on maximum entropy framework. For someone interested in Gross Margin,
+ The OpenNLP Document Categorizer can classify text into pre-defined categories.
+ It supports multiple classification backends: Maximum Entropy (default), Support Vector
+ Machines (SVM) via the <code>opennlp-ml-libsvm</code> module, and ONNX-based deep
+ learning models via the <code>opennlp-dl</code> module. For someone interested in Gross Margin,
the sample text given below could be classified as GMDecrease
<screen>
<![CDATA[Major acquisitions that have a lower gross margin than the existing network
@@ -84,6 +86,74 @@ double[] outcomes = myCategorizer.categorize(textTokens);
String category = myCategorizer.getBestCategory(outcomes);]]>
</programlisting>
</para>
+ <section xml:id="tools.doccat.api.svm">
+ <title>Using an SVM Model</title>
+ <para>
+ OpenNLP provides SVM-based document categorization through the
+ <code>DocumentCategorizerSVM</code> class in the <code>opennlp-ml-libsvm</code>
+ module. This implementation uses the
+ <link xmlns:xlink="http://www.w3.org/1999/xlink"
+ xlink:href="https://github.com/rzo1/zlibsvm">zlibsvm</link> library,
+ an object-oriented Java binding for LIBSVM.
+ </para>
+ <para>
+ SVM text classification supports configurable term weighting strategies
+ (binary, TF, TF-IDF, log-normalized TF), feature selection strategies
+ (information gain, chi-square, term frequency, document frequency),
+ feature scaling, and all SVM parameters (kernel type, cost, gamma, etc.).
+ </para>
+ <para>
+ To use SVM-based categorization, first add the dependency:
+ <programlisting language="xml">
+<![CDATA[<dependency>
+ <groupId>org.apache.opennlp</groupId>
+ <artifactId>opennlp-ml-libsvm</artifactId>
+ <version>CURRENT_OPENNLP_VERSION</version>
+</dependency>]]>
+ </programlisting>
+ </para>
+ <para>
+ Training an SVM model from <code>DocumentSample</code> data:
+ <programlisting language="java">
+<![CDATA[// Configure the SVM categorizer
+SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.TF_IDF)
+ .setFeatureSelectionStrategy(FeatureSelectionStrategy.INFORMATION_GAIN)
+ .setMaxFeatures(1000)
+ .setScaleFeatures(true)
+ .setScaleRange(0.0, 1.0)
+ .build();
+
+// Train from a DocumentSample stream with a bag-of-words feature generator
+FeatureGenerator featureGenerator = new BagOfWordsFeatureGenerator();
+SvmDoccatModel model = DocumentCategorizerSVM.train("eng", sampleStream, config, featureGenerator);
+
+// Serialize the model
+try (OutputStream out = new FileOutputStream("doccat-svm.bin")) {
+ model.serialize(out);
+}]]>
+ </programlisting>
+ </para>
+ <para>
+ Classifying text with the trained model:
+ <programlisting language="java">
+<![CDATA[// Load the model
+SvmDoccatModel model;
+try (InputStream in = new FileInputStream("doccat-svm.bin")) {
+ model = SvmDoccatModel.deserialize(in);
+}
+
+// Classify
+DocumentCategorizer categorizer = new DocumentCategorizerSVM(model, new BagOfWordsFeatureGenerator());
+double[] outcomes = categorizer.categorize(new String[]{"tokens", "of", "text"});
+String bestCategory = categorizer.getBestCategory(outcomes);]]>
+ </programlisting>
+ </para>
+ </section>
<section xml:id="tools.doccat.api.onnx">
<title>Using an ONNX Model</title>
<para>
@@ -172,5 +242,46 @@ try {
</programlisting>
</para>
</section>
+ <section xml:id="tools.doccat.training.svm">
+ <title>Training an SVM Model</title>
+ <para>
+ The SVM-based document categorizer uses the same <code>DocumentSample</code> training
+ format as the Maximum Entropy categorizer. Training is done through the
+ <code>DocumentCategorizerSVM.train()</code> method with a
+ <code>SvmDoccatConfiguration</code> that controls the SVM parameters, term
+ weighting, feature selection, and feature scaling.
+ </para>
+ <para>
+ A typical SVM text classification pipeline uses a LINEAR kernel with
+ binary or TF-IDF term weighting and information gain or chi-square feature
+ selection:
+ <programlisting language="java">
+<![CDATA[SvmDoccatConfiguration config = new SvmDoccatConfiguration.Builder()
+ .setSvmConfiguration(new SvmConfigurationImpl.Builder()
+ .setKernelType(KernelType.LINEAR)
+ .setProbability(true)
+ .build())
+ .setTermWeightingStrategy(TermWeightingStrategy.TF_IDF)
+ .setFeatureSelectionStrategy(FeatureSelectionStrategy.INFORMATION_GAIN)
+ .setMaxFeatures(1000)
+ .setScaleFeatures(true)
+ .build();
+
+SvmDoccatModel model = DocumentCategorizerSVM.train("eng", sampleStream, config,
+ new BagOfWordsFeatureGenerator());]]>
+ </programlisting>
+ </para>
+ <para>
+ The resulting <code>SvmDoccatModel</code> can be serialized and deserialized
+ using Java serialization:
+ <programlisting language="java">
+<![CDATA[// Save
+model.serialize(new FileOutputStream("svm-doccat.bin"));
+
+// Load
+SvmDoccatModel loaded = SvmDoccatModel.deserialize(new FileInputStream("svm-doccat.bin"));]]>
+ </programlisting>
+ </para>
+ </section>
</section>
</chapter>
diff --git a/opennlp-docs/src/docbkx/project-structure.xml b/opennlp-docs/src/docbkx/project-structure.xml
index 8a691c65..e8391bfc 100644
--- a/opennlp-docs/src/docbkx/project-structure.xml
+++ b/opennlp-docs/src/docbkx/project-structure.xml
@@ -121,6 +121,16 @@ under the License.
Add this dependency if your models use the Naive Bayes trainer.
</para>
</listitem>
+ <listitem>
+ <para>
+ <code>opennlp-ml-libsvm</code> — Support Vector Machine (SVM) based text classification
+ via <link xlink:href="https://github.com/rzo1/zlibsvm">zlibsvm</link>. This module provides
+ <code>DocumentCategorizerSVM</code>, an SVM-based implementation of the
+ <code>DocumentCategorizer</code> interface with configurable term weighting
+ (binary, TF, TF-IDF, log-normalized TF), feature selection (information gain,
+ chi-square, term frequency, document frequency), and feature scaling.
+ </para>
+ </listitem>
</itemizedlist>
<para>
diff --git a/opennlp-tools/pom.xml b/opennlp-tools/pom.xml
index a0c801ec..0d5e4577 100644
--- a/opennlp-tools/pom.xml
+++ b/opennlp-tools/pom.xml
@@ -74,6 +74,11 @@
<groupId>${project.groupId}</groupId>
<scope>runtime</scope>
</dependency>
+ <dependency>
+ <artifactId>opennlp-ml-libsvm</artifactId>
+ <groupId>${project.groupId}</groupId>
+ <scope>runtime</scope>
+ </dependency>
<!-- External dependencies -->
<dependency>
diff --git a/pom.xml b/pom.xml
index 177a5af1..7bf30235 100644
--- a/pom.xml
+++ b/pom.xml
@@ -161,6 +161,12 @@
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <artifactId>opennlp-ml-libsvm</artifactId>
+ <groupId>${project.groupId}</groupId>
+ <version>${project.version}</version>
+ </dependency>
+
<dependency>
<artifactId>opennlp-model-resolver</artifactId>
<groupId>${project.groupId}</groupId>
@@ -242,6 +248,7 @@
<log4j2.version>2.25.3</log4j2.version>
<logcaptor.version>2.12.5</logcaptor.version>
<classgraph.version>4.8.184</classgraph.version>
+ <zlibsvm.version>2.1.2</zlibsvm.version>
<jmh.version>1.37</jmh.version>
<!-- Plugin versions -->