Recursive AI

Train a text classifier from labeled JSONL in C#

OneNLP is a free, open-source natural language processing library for .NET from Recursive AI. MIT or Apache-2.0, no native dependencies.

A text classifier assigns categories to whole documents. Train one from labeled examples, then read category probabilities from new text.

Terminal
dotnet add package RecursiveAI.OneNlp.Neural

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

train.jsonl is in the example zip; its complete contents are shown below.

C#
using RecursiveAI.OneNlp;
using RecursiveAI.OneNlp.Data;
using RecursiveAI.OneNlp.Neural.Components;
using RecursiveAI.OneNlp.Neural.Encoders;
 
using var nlp = Pipeline.Blank().Add(
    new TextClassifier(FastEncoder.English(7))
        { OwnsEncoder = true });
// train.jsonl is in the example zip.
var examples = JsonlReader.Read("train.jsonl");
nlp.Train([.. examples],
    new TrainOptions { Epochs = 30, Seed = 7,
        DevFraction = 0 });
var doc = nlp.Process("bread rice");
Console.WriteLine(doc.Cats["FOOD"]);
Text
1

Unzip the example, then dotnet run.

Full program

The snippet at the top uses this invented train.jsonl file from the zip. The full program creates those same training examples in code, holds out “soup rice” (FOOD) and “horse cat” (ANIMAL) for development, then predicts “bread rice”. These tiny inputs demonstrate the API, not real-world accuracy.

JSON
{"text":"bread soup","cats":{"FOOD":1,"ANIMAL":0}}
{"text":"rice bread","cats":{"FOOD":1,"ANIMAL":0}}
{"text":"cat dog","cats":{"FOOD":0,"ANIMAL":1}}
{"text":"dog horse","cats":{"FOOD":0,"ANIMAL":1}}
C#
using RecursiveAI.OneNlp;
using RecursiveAI.OneNlp.Neural.Components;
using RecursiveAI.OneNlp.Neural.Encoders;
 
using var nlp = Pipeline.Blank(Language.English)
    .Add(new TextClassifier(FastEncoder.English(7))
        { OwnsEncoder = true });
Example Labeled(string text, string label) =>
    Example.Create(nlp, text, cats: new Dictionary<string, float>
        { ["FOOD"] = label == "FOOD" ? 1 : 0,
          ["ANIMAL"] = label == "ANIMAL" ? 1 : 0 });
var train = new[] { Labeled("bread soup", "FOOD"),
    Labeled("rice bread", "FOOD"), Labeled("cat dog", "ANIMAL"),
    Labeled("dog horse", "ANIMAL") };
var dev = new[] { Labeled("soup rice", "FOOD"),
    Labeled("horse cat", "ANIMAL") };
nlp.Train(train, new TrainOptions { Epochs = 30, Seed = 7,
    DevExamples = dev, Score = DevScores.Accuracy() });
foreach (var cat in nlp.Process("bread rice").Cats.OrderBy(c => c.Key))
    Console.WriteLine($"{cat.Key}: {cat.Value:F4}");

dotnet run prints:

Text
ANIMAL: 0.0000
FOOD: 1.0000

Variations

Save and reload the trained pipeline. Add this namespace at the top and the remaining lines after training in the full program.

C#
using RecursiveAI.OneNlp.Neural;
 
// nlp is the trained pipeline in the full program.
PipelineFile.Save(nlp, "topics.onenlp");
using var loaded = PipelineFile.Load("topics.onenlp");
Console.WriteLine(loaded.Process("bread rice").Cats["FOOD"].ToString("F4"));
Text
1.0000

The short answer

Add a TextClassifier to a blank pipeline, supply training examples with category labels and call Train. Then process new text and inspect doc.Cats, a dictionary from label to probability. The example uses a seeded FastEncoder and trains locally; it does not require a hosted prediction service or a downloaded language model.

How do I train a text classifier in C#?

Start by defining the categories you actually want to distinguish. The fixture uses FOOD and ANIMAL. Each example gives one category a target of 1 and the other a target of 0.

The snippet at the top reads JSON Lines, one document per line. A line contains a text string and a cats object with label scores. A missing label counts as zero, according to the classification data contract. Be careful when constructing partially labeled data: an omitted category does not mean “unknown” to this reader.

The full program uses Example.Create instead, so all inputs and targets are visible in C#. Its Labeled helper creates the same two target scores for every document.

What does the classifier learn?

TextClassifier averages token vectors from its encoder into a document representation and trains a linear classification head. An optional hidden ReLU layer is available, but the example uses the default head. The encoder and classifier belong to the pipeline, with OwnsEncoder ensuring the encoder is disposed with it.

A seed makes the example’s initialization and training settings explicit. It is not evidence of useful accuracy. Four invented training phrases do not establish performance on customer tickets, sentiment, topics or any other real corpus. The printed probabilities are what this particular demonstration produces for its held-out phrase.

Should categories be exclusive or multi-label?

The default classifier uses a softmax for mutually exclusive categories. Its scores sum to one. That fits a routing task where every document must choose exactly one destination. The program prints both probabilities, not just the winning label, so you can see the complete output shape.

For documents that can belong to several categories at once, the constructor’s multiLabel option selects independent sigmoids instead. Those scores do not compete for a shared total of one. Decide which interpretation matches your annotations before training; do not reinterpret an exclusive model’s probabilities as independent category decisions afterward. The classification documentation describes both modes.

How should I separate training and development data?

The full program passes DevExamples explicitly and uses DevScores.Accuracy(). Its development phrases are different from the training phrases, and the final prediction phrase is separate again. For an application, hold out representative documents that reflect the inputs you expect to receive, rather than judging the model on the same strings used to fit it.

The save-and-load variation preserves the trained pipeline for inference. Keep that file with the label meanings and the data version used to train it. Loading the model avoids retraining every time your application starts; it does not update the model with new examples. If you need labels for ranges inside documents rather than whole-document categories, use an entity recognizer or span categorizer instead.

All onenlp recipes