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