One recipe per thing people actually do. The quick start is the tour; this is the page you come back to.
Every elixir snippet here is executed by mix check.all
(test/integration/guides_test.exs), in order, sharing one set of bindings and one session. The
pattern matches are the assertions. Every model fitted below is released before the page ends.
Where a recipe departs from PySpark, docs/deviations.md has the reason.
Setting up
alias Latu.ML
alias Latu.ML.{Classification, Clustering, Evaluation, Feature, Regression}
{:ok, session} = Latu.connect("sc://localhost:15003")One frame carries the page. It has a binary label for the classifiers, a continuous y for the
regression, and two features. Twelve rows, so that a two-fold search later on has something to
cut:
data =
Latu.sql!(session, """
SELECT CAST(label AS DOUBLE) AS label, CAST(y AS DOUBLE) AS y,
CAST(x1 AS DOUBLE) AS x1, CAST(x2 AS DOUBLE) AS x2
FROM VALUES
(0.0, 1.0, 0.0, 1.1), (0.0, 1.2, 0.1, 1.0), (0.0, 1.4, 0.2, 1.3),
(0.0, 1.6, 0.3, 1.2), (0.0, 1.8, 0.4, 1.1), (0.0, 2.0, 0.5, 1.4),
(1.0, 4.0, 2.0, 0.1), (1.0, 4.2, 2.1, 0.2), (1.0, 4.4, 2.2, 0.3),
(1.0, 4.6, 2.3, 0.1), (1.0, 4.8, 2.4, 0.2), (1.0, 5.0, 2.5, 0.4)
AS t(label, y, x1, x2)
""")
12 = Latu.count!(data)Every estimator wants one Vector column. VectorAssembler builds it, and applying it is a
lazy builder. The frame below has reached no server:
assembler = Feature.vector_assembler(input_cols: [:x1, :x2], output_col: :features)
features = ML.transform(assembler, data)
["label", "y", "x1", "x2", "features"] = Latu.columns!(features)A classifier, end to end
The shape every recipe follows: build the estimator, fit it, read something off it, score with it, release it.
alias Latu.ML.Classification.{BinaryLogisticRegressionSummary, LogisticRegressionModel}
lr = Classification.logistic_regression(max_iter: 10, reg_param: 0.01)
{:ok, model} = ML.fit(lr, features)
{:ok, coefficients} = LogisticRegressionModel.coefficients(model)
{:ok, intercept} = LogisticRegressionModel.intercept(model)
{2} = Nx.shape(coefficients)
true = is_float(intercept)The training summary is a second server-side object, reached lazily off the model:
summary = ML.summary(model)
{:ok, auc} = BinaryLogisticRegressionSummary.area_under_roc(summary)
{:ok, accuracy} = BinaryLogisticRegressionSummary.accuracy(summary)
true = auc >= 0.0 and auc <= 1.0
true = accuracy >= 0.0 and accuracy <= 1.0Scoring is a lazy builder too, so a scored frame composes like any other. Select away the
Vector columns before collecting: features, rawPrediction and probability are UDTs the
decoder refuses:
scored = ML.transform(model, features)
{:ok, rows} = scored |> Latu.select([:label, :prediction]) |> Latu.collect()
12 = length(rows)
true = Enum.all?(rows, &(&1.prediction in [0.0, 1.0]))An evaluator is inert data, so a second metric is a second value rather than an override at the call site:
{:ok, evaluated} = ML.evaluate(Evaluation.binary_classification_evaluator(), scored)
true = evaluated >= 0.0 and evaluated <= 1.0
:ok = ML.delete(model)A regression, and what its summary knows
label_col: points an estimator at a different target column; the features column is
features by default, which is why nothing above had to name it.
alias Latu.ML.Regression.{LinearRegressionModel, LinearRegressionTrainingSummary}
{:ok, linear} = ML.fit(Regression.linear_regression(max_iter: 10, label_col: :y), features)
{:ok, weights} = LinearRegressionModel.coefficients(linear)
{2} = Nx.shape(weights)A regression summary is where the diagnostics live. Some of it is values and some of it is
frames. r2 is a number and arrives with {:ok, _}; residuals is a Latu.DataFrame and has
run nothing yet:
fit_summary = ML.summary(linear)
{:ok, r2} = LinearRegressionTrainingSummary.r2(fit_summary)
{:ok, rmse} = LinearRegressionTrainingSummary.root_mean_squared_error(fit_summary)
{:ok, iterations} = LinearRegressionTrainingSummary.total_iterations(fit_summary)
true = r2 >= 0.0 and r2 <= 1.0
true = rmse >= 0.0
true = is_integer(iterations)
residuals = LinearRegressionTrainingSummary.residuals(fit_summary)
["residuals"] = Latu.columns!(residuals)
:ok = ML.delete(linear)That split runs through the whole package: Latu.ML.attribute/2 for a value, and
Latu.ML.attribute_frame/2 for a frame, each refusing the other's names. The generated
accessors pick for you.
A decision tree, and what a tree will not give you
A tree fits like anything else, and the interesting attributes are about its shape:
alias Latu.ML.Classification.DecisionTreeClassificationModel
{:ok, tree} = ML.fit(Classification.decision_tree_classifier(max_depth: 3, seed: 1), features)
{:ok, depth} = DecisionTreeClassificationModel.depth(tree)
{:ok, nodes} = DecisionTreeClassificationModel.num_nodes(tree)
true = depth >= 1
true = nodes >= 3feature_importances is the one attribute on this page that does not come back as a tensor.
Spark builds it with Vectors.sparse, because a tree usually ignores some of its features, and
this package never densifies for you. A vector that is sparse is generally sparse on purpose:
{:ok, importances} = DecisionTreeClassificationModel.feature_importances(tree)
%Latu.ML.SparseVector{size: 2} = importances
{2} = importances |> Latu.ML.SparseVector.to_dense() |> Nx.shape()Latu.ML.SparseVector.to_dense/1 is the caller's decision, and Nx is where it lands.
What you cannot get is the tree itself. The allowlist gives a tree its depth, its node count,
its feature importances, the four predict* methods and to_debug_string. The predict* methods
are one round trip per row, so not a scoring path. The debug string has no compatibility promise.
There is no node table and no thresholds as data:
{:ok, dump} = DecisionTreeClassificationModel.to_debug_string(tree)
true = is_binary(dump)So a tree is scored where it lives, with Latu.ML.transform/2. The parameters-out seam in
With Nx and Scholar ends at the model families whose parameters are
the model.
An ensemble adds one shape worth knowing: trees answers with a list of cache entries, one
per tree, and they are yours to give back.
alias Latu.ML.Classification.RandomForestClassificationModel
forest = Classification.random_forest_classifier(num_trees: 3, max_depth: 3, seed: 1)
{:ok, rf} = ML.fit(forest, features)
{:ok, n} = RandomForestClassificationModel.get_num_trees(rf)
{:ok, trees} = RandomForestClassificationModel.trees(rf)
3 = n
3 = length(trees)
:ok = ML.delete(trees)
:ok = ML.delete([tree, rf])Latu.ML.delete/1 takes a list, and a mixed one: models, pipeline models and search results in
the same call become a single Delete.
Clustering, with no labels to speak of
Unsupervised is the same shape with the label column absent:
alias Latu.ML.Clustering.{KMeansModel, KMeansSummary}
{:ok, kmeans} = ML.fit(Clustering.k_means(k: 2, seed: 1), features)
{:ok, centres} = KMeansModel.cluster_center_matrix(kmeans)
{2, 2} = Nx.shape(centres)A Matrix comes back row-major whatever Spark's isTransposed flag says. A storage flag is not
a shape. The summary carries the sizes and the cost:
clustering = ML.summary(kmeans)
{:ok, sizes} = KMeansSummary.cluster_sizes(clustering)
{:ok, cost} = KMeansSummary.training_cost(clustering)
2 = length(sizes)
12 = Enum.sum(sizes)
true = cost >= 0.0
:ok = ML.delete(kmeans)A pipeline, saved and read back
Pipeline has no Fit on the wire, so both PySpark and this package fold over the stages
themselves. Which means the thing that has to match is the on-disk format, not the wire.
scaler = Feature.standard_scaler(input_col: :features, output_col: :scaled, with_mean: true)
pipeline =
ML.pipeline([
assembler,
scaler,
Classification.logistic_regression(max_iter: 10, features_col: :scaled)
])
{:ok, fitted} = ML.fit(pipeline, data)
%Latu.ML.PipelineModel{stages: [_assembler, _scaler, _lr]} = fittedThe fitted pipeline transforms in one call, composing each stage's relation lazily:
piped = ML.transform(fitted, data)
{:ok, piped_rows} = piped |> Latu.select([:label, :prediction]) |> Latu.collect()
12 = length(piped_rows)Saving writes Spark's own directory layout to a path on the server. The write happens where the session is, so a bare path is the driver's disk. Anything a second machine has to read wants a URL the cluster's filesystem understands:
path = "/tmp/latu_ml_cookbook/#{System.unique_integer([:positive])}"
:ok = ML.save(fitted, path, overwrite: true)
{:ok, reloaded} = ML.load(session, :pipeline_model, path)
%Latu.ML.PipelineModel{stages: [_, _, _]} = reloadedA loaded model holds a fresh cache reference. Reading one costs a cache entry exactly as fitting one does. It carries no training frame, so it has no summary and nothing to rebuild one from.
:ok = ML.delete([fitted, reloaded])Two things about that directory before you rely on it. Each stage underneath is written by the
server, so every stage is in Scala's own format and loads anywhere. The wrapper is not: its
metadata says class: "pyspark.ml.pipeline.Pipeline", which Scala's reader refuses. It refuses
PySpark's own saved pipelines too, for the same one key.
Searching a grid
ML.param_grid/2 builds the grid as data. Each entry carries the uid of the operator it
sets, because the thing searched is usually a pipeline and a param belongs to one stage of it:
tuned = Classification.logistic_regression(max_iter: 10)
grid = ML.param_grid(tuned, reg_param: [0.0, 0.1])
2 = length(grid)The search itself is client code on both sides: folds cut with rand(seed) and range filters,
exactly as CrossValidator._kFold does it. Pass a seed:. Without one the draw differs between
runs and the search is not repeatable:
search =
ML.cross_validator(
estimator: tuned,
param_maps: grid,
evaluator: Evaluation.binary_classification_evaluator(),
num_folds: 2,
seed: 1
)
{:ok, best} = ML.fit(search, features)
2 = length(best.avg_metrics)
true = ML.best_index(best) in [0, 1]The winner is refit on the whole frame and lives in best_model; the fold models are released
as their metrics are read, unless you asked for them with collect_sub_models: true.
%Latu.ML.Model{} = best.best_model
:ok = ML.delete(best)Latu.ML.larger_better?/1 decides argmax from argmin, and it is pure. The server's allowlist has
no isLargerBetter, so the registry carries PySpark's own client-side overrides.
true = ML.larger_better?(Evaluation.binary_classification_evaluator())
false = ML.larger_better?(Evaluation.regression_evaluator(metric_name: "rmse"))Cleaning up after yourself
A model is a server-side resource with no finalizer behind it. Three things help.
Latu.ML.with_model/3 is the bracket: acquire, use, release in an after. It is the right shape
whenever the handle does not have to outlive the expression:
{:ok, bracketed} =
ML.with_model(Classification.logistic_regression(max_iter: 5), features, fn m ->
ML.evaluate(Evaluation.binary_classification_evaluator(), ML.transform(m, features))
end)
true = is_float(bracketed)Latu.ML.cache_info/1 says what the session is actually holding, and Latu.ML.model_size/1
what one entry costs. Every recipe above deleted what it fitted:
{:ok, entries} = ML.cache_info(session)
[] = entriesLatu.ML.clean_cache/1 empties the lot, and answers with how many entries it freed rather than a
bare :ok. An empty cache is a legitimate zero:
{:ok, 0} = ML.clean_cache(session)Ending the session ends everything in it, which is the backstop rather than the plan:
Latu.disconnect(session, release: true)Where to go next
- Coming from PySpark ML. The five differences, and what a model is.
- With Nx and Scholar. The seam back onto the BEAM, and where it ends.
docs/deviations.md. The spelling table, and the reason for each row.