Current section

Files

Jump to
penelope lib penelope ml svm classifier.ex
Raw

lib/penelope/ml/svm/classifier.ex

defmodule Penelope.ML.SVM.Classifier do
@moduledoc """
The SVM classifier uses libsvm for multi-class classification. It provides
support for training a model, compiling/extracting model parameters to/from
erlang data structures, and predicting classes or probabilities.
Features are represented as lists of dense Vector instances. Classes can
be any value, and class labels for training are lists of these.
Model parameters are elixir analogs of those supported by libsvm. See
https://github.com/cjlin1/libsvm for details.
"""
alias Penelope.ML.Vector
alias Penelope.NIF
@doc """
trains an SVM model and returns it as a compiled model
|key |description |default |
|--------------|------------------------------------------|---------|
|`kernel` |one of `:linear`/`:rbf`/`:poly`/`:sigmoid`|`:linear`|
|`degree` |polynomial degree |3 |
|`gamma` |training example reach - `:auto` for 1/N |`:auto` |
|`coef0` |independent term |0.0 |
|`c` |error term penalty |1.0 |
|`weights` |class weights map - `:auto` for balanced |`:auto` |
|`epsilon` |tolerance for stopping |0.001 |
|`cache_size` |kernel cache size, in MB |1 |
|`shrinking?` |use the shrinking heuristic? |true |
|`probability?`|enable class probabilities? |false |
"""
@spec fit(
context :: map,
x :: [Vector.t()],
y :: [any],
options :: keyword
) :: map
def fit(_context, x, y, options \\ []) do
if length(x) !== length(y), do: raise(ArgumentError, "mismatched x/y")
classes = Enum.uniq(y)
y = Enum.map(y, &index_of(classes, &1))
params = fit_params(x, y, classes, options)
model = NIF.svm_train(x, y, params)
%{svm: model, classes: classes}
end
@doc """
extracts model parameters from the compiled model
These parameters are simple elixir objects and can later be passed to
`compile` to prepare the model for inference.
"""
@spec export(%{svm: reference, classes: [any]}) :: map
def export(%{svm: model, classes: classes}) do
model
|> NIF.svm_export()
|> Map.put(:classes, classes)
|> Map.update!(:kernel, &to_string/1)
|> Map.update!(:coef, fn l -> Enum.map(l, &Vector.to_list/1) end)
|> Map.update!(:sv, fn l -> Enum.map(l, &Vector.to_list/1) end)
|> Map.update!(:rho, &Vector.to_list/1)
|> Map.update!(:prob_a, fn v -> v && Vector.to_list(v) end)
|> Map.update!(:prob_b, fn v -> v && Vector.to_list(v) end)
|> Map.new(fn {k, v} -> {to_string(k), v} end)
end
@doc """
compiles a pre-trained model
"""
@spec compile(params :: map) :: map
def compile(%{"classes" => classes} = params) do
model =
params
|> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end)
|> Map.put(:classes, Enum.to_list(0..(length(classes) - 1)))
|> Map.update!(:kernel, &String.to_existing_atom/1)
|> Map.update!(:coef, fn l -> Enum.map(l, &Vector.from_list/1) end)
|> Map.update!(:sv, fn l -> Enum.map(l, &Vector.from_list/1) end)
|> Map.update!(:rho, &Vector.from_list/1)
|> Map.update!(:prob_a, fn v -> v && Vector.from_list(v) end)
|> Map.update!(:prob_b, fn v -> v && Vector.from_list(v) end)
|> NIF.svm_compile()
%{svm: model, classes: classes}
end
@doc """
predicts a list of target classes from a list of feature vectors
"""
@spec predict_class(%{svm: reference, classes: [any]}, context :: map, [
x :: Vector.t()
]) :: [any]
def predict_class(model, _context, x) do
Enum.map(x, &do_predict_class(model, &1))
end
defp do_predict_class(%{svm: model, classes: classes}, x) do
Enum.at(classes, NIF.svm_predict_class(model, x))
end
@doc """
predicts probabilities for all classes from a feature vector
The results are returned in a map of `%{label => probability}`.
"""
@spec predict_probability(
%{svm: reference, classes: [any]},
context :: map,
[x :: Vector.t()]
) :: [%{any => float}]
def predict_probability(model, _context, x) do
Enum.map(x, &do_predict_probability(model, &1))
end
defp do_predict_probability(%{svm: model, classes: classes}, x) do
model
|> NIF.svm_predict_probability(x)
|> Map.new(fn {k, p} -> {Enum.at(classes, k), p} end)
end
defp fit_params(x, y, classes, options) do
gamma =
with :auto <- Keyword.get(options, :gamma, :auto) do
auto_gamma(x)
end
weights =
case Keyword.get(options, :weights, :auto) do
:auto -> auto_weights(y)
weights -> manual_weights(classes, weights)
end
%{
kernel: Keyword.get(options, :kernel, :linear),
degree: Keyword.get(options, :degree, 3),
gamma: gamma / 1,
coef0: Keyword.get(options, :coef0, 0) / 1,
c: Keyword.get(options, :c, 1) / 1,
weights: weights,
epsilon: Keyword.get(options, :epsilon, 1.0e-3) / 1,
cache_size: Keyword.get(options, :cache_size, 1) / 1,
shrinking?: Keyword.get(options, :shrinking?, true),
probability?: Keyword.get(options, :probability?, false)
}
end
defp auto_gamma([x | _]) do
1.0 / Vector.size(x)
end
defp auto_weights(y) do
# class frequencies, sample count, and class count
f = Enum.reduce(y, %{}, &Map.update(&2, &1, 1, fn f -> f + 1 end))
m = Enum.sum(Map.values(f))
k = Enum.count(f)
# weight = samples / (classes * frequency)
f
|> Map.keys()
|> Enum.reduce(f, &Map.update!(&2, &1, fn f -> m / (k * f) end))
end
defp manual_weights(classes, weights) do
Map.new(weights, fn {k, v} -> {index_of(classes, k), v} end)
end
defp index_of(l, e) do
Enum.find_index(l, fn x -> x === e end)
end
end