Current section
Files
Jump to
Current section
Files
lib/bin_class.ex
defmodule BinClass do
@moduledoc """
A reusable library for building, training, and using binary classifiers using Axon.
## Usage
### Training
data = [
%{text: "This is a positive text", label: 1},
%{text: "This is a negative text", label: 0},
# ... more data
]
# Use a map for explicit label mapping
classifier = BinClass.Trainer.train(data,
epochs: 5,
labels: %{0 => :negative, 1 => :positive}
)
# The default model is v7 (:conservative_cnn). It uses a 512-token
# vector length, false-positive-aware checkpoint selection, and
# persisted threshold calibration.
# Save the model
BinClass.save(classifier, "model.bin")
### Prediction
# Load the model as a serving (supports custom compiler/defn_options)
serving = BinClass.load("model.bin", compiler: EXLA)
result = Nx.Serving.run(serving, "Some text to classify")
# result is %{label: :positive, confidence: 0.99, ...}
### Ultra-Low Latency Inference
# Load raw classifier and compile a predictor function
classifier = BinClass.load_classifier("model.bin")
predict = BinClass.compile_predictor(classifier)
result = predict.("Instant prediction")
"""
@doc """
Saves the classifier to a file.
"""
def save(%BinClass.Classifier{} = classifier, path) do
binary = serialize(classifier)
File.write!(path, binary)
end
@doc """
Serializes the classifier to a binary.
The serialized data includes tokenizer state, model parameters, labels,
vector length, model version, training metadata, and any calibrated
decision policy. Classifiers saved before decision policies existed remain
readable via `deserialize_classifier/1`.
"""
def serialize(%BinClass.Classifier{} = classifier) do
# Ensure model params are on Binary backend for safe serialization
model_params_binary = Nx.backend_copy(classifier.model_params, Nx.BinaryBackend)
data = %{
tokenizer_json: BinClass.Tokenizer.to_json(classifier.tokenizer),
model_params: model_params_binary,
vector_length: classifier.vector_length,
vocab_size: classifier.vocab_size,
labels: classifier.labels,
accuracy: classifier.accuracy,
epoch: classifier.epoch,
model_version: classifier.model_version,
learning_rate: classifier.learning_rate,
dropout_rate: classifier.dropout_rate,
decision_policy: classifier.decision_policy
}
:erlang.term_to_binary(data)
end
@doc """
Loads a saved model from a file and returns an Nx.Serving struct.
"""
def load(path, opts \\ []) do
classifier = load_classifier(path)
serving_opts =
Keyword.merge(opts,
vector_length: classifier.vector_length,
vocab_size: classifier.vocab_size,
labels: classifier.labels,
model_version: classifier.model_version,
dropout_rate: classifier.dropout_rate || 0.2,
decision_policy: classifier.decision_policy
)
BinClass.Serving.new(classifier.model_params, classifier.tokenizer, serving_opts)
end
@doc """
Deserializes a saved model from a binary and returns an Nx.Serving struct.
"""
def deserialize(binary, opts \\ []) when is_binary(binary) do
classifier = deserialize_classifier(binary)
serving_opts =
Keyword.merge(opts,
vector_length: classifier.vector_length,
vocab_size: classifier.vocab_size,
labels: classifier.labels,
model_version: classifier.model_version,
dropout_rate: classifier.dropout_rate || 0.2,
decision_policy: classifier.decision_policy
)
BinClass.Serving.new(classifier.model_params, classifier.tokenizer, serving_opts)
end
@doc """
Loads a saved model from a file and returns a BinClass.Classifier struct.
"""
def load_classifier(path) do
binary = File.read!(path)
deserialize_classifier(binary)
end
@doc """
Deserializes a saved model from a binary and returns a BinClass.Classifier struct.
Older binaries that do not contain `:decision_policy` load with
`decision_policy: nil` and use version defaults at inference time.
"""
def deserialize_classifier(binary) when is_binary(binary) do
data = :erlang.binary_to_term(binary)
{:ok, tokenizer} = Tokenizers.Tokenizer.from_buffer(data.tokenizer_json)
%BinClass.Classifier{
tokenizer: tokenizer,
model_params: data.model_params,
vector_length: data.vector_length,
vocab_size: data.vocab_size,
labels: data.labels,
accuracy: Map.get(data, :accuracy),
epoch: Map.get(data, :epoch),
model_version: Map.get(data, :model_version, 1),
learning_rate: Map.get(data, :learning_rate),
dropout_rate: Map.get(data, :dropout_rate, 0.2),
decision_policy: Map.get(data, :decision_policy)
}
end
@doc """
Compiles the classifier into a highly optimized, in-process prediction function.
This is intended for scenarios where lowest possible latency is required
and batching (provided by Nx.Serving) is not necessary (e.g. CLI tools,
single-user scripts, or very low-concurrency high-speed inference).
Returns an anonymous function that takes a text (string) or list of texts
and returns the classification results.
## Options
* `:compiler` - The compiler to use. Defaults to `EXLA`.
* `:batch_size` - The batch size to compile for. Defaults to 1 (lowest latency).
"""
def compile_predictor(%BinClass.Classifier{} = classifier, opts \\ []) do
compiler = Keyword.get(opts, :compiler, EXLA)
batch_size = Keyword.get(opts, :batch_size, 1)
model =
BinClass.Model.build(classifier.model_version, classifier.vocab_size,
dropout_rate: classifier.dropout_rate || 0.2
)
{_, predict_fn} = Axon.build(model, compiler: compiler)
policy =
classifier.decision_policy || BinClass.Serving.decision_policy(classifier.model_version)
use_decision_policy? = BinClass.Serving.decision_policy?(classifier.model_version)
# Compile the prediction function specifically for the given batch size
template = Nx.broadcast(0, {batch_size, classifier.vector_length}) |> Nx.as_type(:u16)
# Warmup / JIT compilation
_ = predict_fn.(classifier.model_params, template)
fn input ->
{texts, multi?} = BinClass.Serving.validate_input(input)
# Pad or truncate to batch_size
batch_texts =
if length(texts) < batch_size do
# Pad with empty strings if smaller than compiled batch size
texts ++ List.duplicate("", batch_size - length(texts))
else
# Take only up to compiled batch size
Enum.take(texts, batch_size)
end
vectors =
batch_texts
|> Enum.map(fn text ->
BinClass.Vectorizer.build(classifier.tokenizer, text, classifier.vector_length)
end)
active_lengths =
vectors
|> Enum.map(fn vector -> Enum.count(vector, &(&1 != 0)) end)
batch_tensor =
vectors
|> Enum.map(&Nx.tensor(&1, type: :u16))
|> Nx.Batch.stack()
# Run inference directly
batch_output =
predict_fn.(classifier.model_params, batch_tensor)
|> Nx.backend_transfer(Nx.BinaryBackend)
# Decode only the requested number of items
results =
batch_output
|> Nx.to_list()
|> Enum.take(length(texts))
|> Enum.zip(active_lengths)
|> Enum.map(fn {probs, active_length} ->
if use_decision_policy? do
BinClass.Serving.decode_prediction(
probs,
classifier.labels,
policy.positive_threshold,
active_length,
policy.min_positive_tokens
)
else
BinClass.Serving.decode_prediction(probs, classifier.labels)
end
end)
if multi?, do: results, else: List.first(results)
end
end
end