latu_ml is Spark MLlib over Spark Connect: the algorithms run on the cluster and the Elixir
side holds plans and handles. This file is the short set of rules that are not guessable from
the function names. It follows the usage_rules convention, so a consuming project can sync it
into an agent's context.
Latu's own usage-rules.md applies here in full:
coercion, the ! twins, errors, session config. What follows is the delta. Every deliberate
departure from pyspark.ml is in docs/deviations.md, with why.
The three shapes
Latu has two; this has three, and the third is the one that surprises.
A builder takes a struct and returns a struct. Pure, no IO. Latu.ML.pipeline/1,
Latu.ML.param_grid/2, Latu.ML.cross_validator/1, and every generated constructor:
Latu.ML.Classification.logistic_regression/1 and its sixty-seven siblings.
An action runs something and returns {:ok, value} | {:error, %Latu.Error{}}, with a !
twin. Latu.ML.fit/2, Latu.ML.evaluate/2, Latu.ML.attribute/2, Latu.ML.save/3,
Latu.ML.load/4, Latu.ML.delete/1, Latu.ML.model_size/1, Latu.ML.cache_info/1,
Latu.ML.clean_cache/1.
A relation builder returns a Latu.DataFrame and reaches no server. Latu.ML.transform/2
is one. Applying a transformer or a fitted model builds a plan, nothing more, and
Latu.schema/1 answers on it without executing. So is Latu.ML.attribute_frame/2, and every
generated accessor for a DataFrame-valued attribute. So are Latu.ML.Stat's three tests and
Latu.ML.assign_clusters/2.
features = Latu.ML.transform(assembler, training) # nothing has run
{:ok, model} = Latu.ML.fit(lr, features) # one round trip
scored = Latu.ML.transform(model, features) # still nothingA model is a server-side resource, and it is yours to release
Latu.ML.fit/2 hands back a Latu.ML.Model: a reference into a per-session cache, not a value.
The BEAM has no finalizer, so nothing here can free it for you.
# the bracket — deletes in an `after`, so a raise in the body still releases
{:ok, auc} =
Latu.ML.with_model(lr, training, fn model ->
Latu.ML.evaluate(evaluator, Latu.ML.transform(model, test))
end)
# the pair, for when the handle outlives the expression
{:ok, model} = Latu.ML.fit(lr, training)
:ok = Latu.ML.delete(model)Latu.ML.delete/1 takes a list, and a mixed one. A Latu.ML.PipelineModel or a search result
owns the models inside it, so deleting one empties the tree in a single Delete.
Two things also spend cache, and are easy to miss: Latu.ML.load/4 on a model registers a
fresh entry, exactly as a fit does; and a trees accessor answers with one cache entry per
tree. Latu.ML.cache_info/1 is how you find out what you are holding.
What you do not have to guard against is eviction. A model is offloaded to disk under memory pressure and comes back transparently; over budget it is the fit that is refused rather than something already held being dropped.
Params: snake_case, only what you set, kind not range
A constructor takes a keyword list in Elixir's spelling: max_iter: is maxIter on the wire.
Only the params you set are sent; the server fills in every default. A documented default is
never sent, because a param you did not set and a param sent with its default value are
different requests.
Client-side validation refuses the kind and never the range. reg_param: :bad is this
package's error; reg_param: -1.0 is Spark's own ParamValidators' error. A column is an atom,
as everywhere in Latu.
Latu.ML.params/1 prints the table for an operator, with each param's type and default.
Attributes: the server has an allowlist, and there are two verbs
MLUtils on the server holds a list of the methods a fetch may invoke on each model and summary
class; anything else is CONNECT_ML.ATTRIBUTE_NOT_ALLOWED. The set is not "whatever PySpark
exposes". Latu.ML.attributes/1 is what it actually is.
Reach for the generated accessor first. There is one function per allowlisted attribute, on
a module per class, carrying the class, the wire name and Spark's own docs:
Latu.ML.Regression.LinearRegressionModel.coefficients/1. It checks the class before the round
trip; the generic verbs underneath do not.
Which generic verb depends on how the attribute answers, and they refuse each other's names:
Latu.ML.attribute/2for a value:{:ok, term}, an action. AVectororMatrixcomes back as anNx.Tensor, or aLatu.ML.SparseVectorwhere densifying would be this package's decision rather than yours. AMatrixis always row-major.Latu.ML.attribute_frame/2for a DataFrame-valued one: a lazyLatu.DataFrame.
Either verb takes either spelling: the snake_case name Latu.ML.attributes/1 advertises, or the
camelCase one the allowlist holds.
Summaries
Ten of the 43 model classes record a training summary. Latu.ML.summary/1 is a lazy builder.
Spark reaches a summary through the model that owns it, so the reference is composed locally and
nothing is sent until you ask it for something. It raises for a class that has none, naming the
ten that do.
The cache drops a summary rather than offloading it, so a fetch can answer
CONNECT_ML.MODEL_SUMMARY_LOST through no fault of yours. Latu.ML.attribute/2 recovers from
that on its own, from the frame the model was fitted on. Except for a loaded model, which
never saw the data: there the server's refusal reaches you, and Latu.ML.hint/1 says why.
A Vector column cannot be collected
On Spark 4.2.0 the server describes features, rawPrediction and probability as UDTs with
no SQL type, so Latu's dtype guard refuses them rather than guessing a layout. This is how
Connect describes the type, not something a fit does. A column you build yourself with
Latu.ML.Functions.array_to_vector/1 is refused the same way.
Three routes through, and the destination picks one:
Latu.to_nx/2reads the Arrow bytes, where the type is stated, and gives one{rows, width}f64tensor. Use it when the destination is a tensor.Latu.ML.Functions.vector_to_array/2converts server-side, so the column arrives as an ordinaryarray<double>any reader takes. Use it when the destination is anExplorer.DataFrame.selectordropthe column, which is often the answer. Apredictioncolumn is a double and was never the problem.
Pipelines and tuning are client code
Spark exposes no Fit for Pipeline, CrossValidator or TrainValidationSplit, so both
PySpark and this package loop over the stages themselves. Which means the wire is not what has
to match; the on-disk format is.
Two consequences before you build one. A Latu.ML.param_grid/2 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. A grid whose params reach nothing is refused before the first fit.
And a search is only repeatable with a seed:. Folds are cut with rand(seed) and range
filters, and without one the draw differs between runs.
Latu.ML.larger_better?/1 turns a metric into an argmax or an argmin. It is pure: the
server's allowlist has no isLargerBetter, so the registry carries PySpark's own client-side
overrides.
Persistence: the path is the server's
Latu.ML.save/3 writes 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.
Latu.ML.load/4 names what is at the path rather than discovering it. A Read has to say
which class to load before the server will look. Three spellings: an operator name from the
registry, a generated model module, or {class, kind} for a class this package does not know.
A bare model's directory is written entirely by the server, so any Spark client reads it. A pipeline's wrapper is written by the client in PySpark's layout, which Scala's reader refuses. It refuses PySpark's own too, for the same one key.
Errors
A %Latu.Error{} carries Spark's own error_class. Match on it, never on the message:
CONNECT_ML.CACHE_INVALID's text calls the missing object a "Summary object" and blames a
15-minute idle eviction, which is wrong for a model you deleted a moment ago.
Latu.ML.error_kind/1 maps the five CONNECT_ML classes to atoms, and Latu.ML.hint/1 turns
one into a sentence naming the fix.
The registry is the reference
Everything the package knows about operators, params and attributes is data, queryable without a server:
Latu.ML.operators(kind: :estimator, group: :classification, status: :probed)
Latu.ML.operator(:logistic_regression)
Latu.ML.params(:logistic_regression)
Latu.ML.attributes(Latu.ML.Classification.LogisticRegressionModel)status: is :probed for an operator a live server has run, with every allowlisted attribute
answered. :built is one generated from PySpark's param table and not yet exercised. :missing
is one PySpark names and the server does not load. Every constructor's @doc opens with its
status, so h tells you what is verified rather than what is claimed.
An unknown filter key raises rather than matching nothing. A model has no constructor to look up, because a model exists only by a fit or a read.
Things this deliberately does not do
- No
predict/2on tensors. MLlib is DataFrame in, DataFrame out, with afeaturesVectorcolumn.Latu.ML.transform/2is the scoring path. The per-rowpredictattribute costs a round trip per row, so it is not one. - No building a model from parameters you hold. There is no wire path: a model exists only
by
Latu.ML.fit/2orLatu.ML.load/4. Nothing goes the other way, including from Scholar. - No closures on the cluster.
predict_batch_udfandxgboost.sparkship Python to Python workers; there is no equivalent for the BEAM. - No parallel grid search yet. PySpark runs the grid on a thread pool. Whether several processes can drive one session's channel at once is unmeasured here, so the option is not offered.
- No process, and no supervision tree entry, exactly as Latu promises. The one thing that
outlives a call is the server-side cache entry, which is why
Latu.ML.delete/1exists.