latu_ml sends the same MlCommands PySpark sends, so what you know about MLlib transfers
whole: the same operators, the same params, the same on-disk format. What changes is what a
model is in your program.
Five differences account for nearly everything. The spelling is
docs/deviations.md: maxIter to max_iter, and twenty-five more rows, each
with its reason.
Every elixir snippet here is executed by mix check.all
(test/integration/guides_test.exs).
alias Latu.ML
alias Latu.ML.Classification.BinaryLogisticRegressionSummary
alias Latu.ML.{Classification, Feature, Functions}
session = Latu.connect!("sc://localhost:15003")
training =
Latu.sql!(session, """
SELECT CAST(label AS DOUBLE) AS label, CAST(x1 AS DOUBLE) AS x1, CAST(x2 AS DOUBLE) AS x2
FROM VALUES
(0.0, 0.0, 1.1), (0.0, 0.1, 1.0), (1.0, 2.0, 0.1), (1.0, 2.1, 0.2)
AS t(label, x1, x2)
""")
assembler = Feature.vector_assembler(input_cols: [:x1, :x2], output_col: :features)
features = ML.transform(assembler, training)1. A model is not a value
In PySpark a fitted model is an object in your process. On Connect it holds a reference to a
server-side one, and a refcount frees it in __del__ when the last name goes out of scope. The
lifetime is invisible and you never think about it.
Here it is visible on purpose. ML.fit/2 hands back a %Latu.ML.Model{}, a reference into a
per-session cache: inert data on the BEAM, something real on the server. It stays real until you
say otherwise, and the BEAM has no finalizers, so there is nothing to hook.
So the model has to be released, and the shape for that is a bracket:
{:ok, bracketed} =
ML.with_model(Classification.logistic_regression(max_iter: 5), features, fn model ->
ML.attribute(model, :intercept)
end)
true = is_float(bracketed)with_model/3 deletes in an after, so a raise in the body still releases. When the handle has
to outlive the expression, fit/2 and delete/1 are the pair. delete/1 on a PipelineModel or
a search result empties the whole tree, because those own the models inside them:
{:ok, model} = ML.fit(Classification.logistic_regression(max_iter: 10), features)
{:ok, coefficients} = ML.attribute(model, :coefficients)
{2} = Nx.shape(coefficients)2. An attribute is an action, and the server has an allowlist
model.coefficients is a property in PySpark that raises on failure. It is a round trip either
way. Here it is spelled as one:
{:ok, intercept} = ML.attribute(model, :intercept)
true = is_float(intercept)The {:ok, _} is Latu's convention for anything that reaches the server, and there is a !
twin. The server decides what you may ask for. MLUtils holds an
allowlist per class, and anything outside it is CONNECT_ML.ATTRIBUTE_NOT_ALLOWED. The set is
not "whatever PySpark exposes":
allowed = ML.attributes(Classification.LogisticRegressionModel)
true = :coefficients in Enum.map(allowed, & &1.name)
true = length(allowed) > 5Reach for the generated accessor first. There is one function per allowlisted attribute, on a
module per model class, carrying the class, the wire name and Spark's own docs. So
h LogisticRegressionModel.coefficients tells you what Spark says. ML.attribute/2 underneath
takes the wire name, which is why
:coefficients works and :area_under_roc would not.
3. A Vector column does not come back as a vector
This is the one that surprises. df.collect() in PySpark hands you DenseVector objects,
because PySpark has a Python class for them. Latu has no such thing, and more to the point Spark
does not describe the column well enough to build one: it says "UDT" and declines to say what it
serialises as.
scored = ML.transform(model, features)
{:error, refused} = Latu.collect(scored)
true = refused.message =~ "UDT that does not say its SQL type"Two ways through, and they answer different questions. Latu.to_nx/2 reads the Arrow bytes,
where the type is stated, and gives you one {rows, width} tensor:
{:ok, %{"features" => x}} = Latu.to_nx(features, columns: ["features"])
{4, 2} = Nx.shape(x)Functions.vector_to_array/2 is pyspark.ml.functions.vector_to_array, and converts on the
server so the column arrives as an ordinary array<double> any reader takes:
{:ok, rows} =
scored
|> Latu.select([:prediction, v: Functions.vector_to_array(:features)])
|> Latu.collect()
4 = length(rows)
[_, _] = hd(rows).vselect or drop the Vector columns is the third way, and often the right one. A prediction
column is a double and was never the problem.
4. A summary is a second object, and the cache drops it
model.summary is a property in PySpark. Here it is a lazy handle to another server-side object,
reached by asking the model. Spark has no separate cache key for one:
summary = ML.summary(model)
{:ok, area} = BinaryLogisticRegressionSummary.area_under_roc(summary)
true = is_float(area) and area >= 0.0 and area <= 1.0The part worth knowing: a model is offloaded under memory pressure, but a summary is
dropped. A model's save format does not carry one, so the model that comes back from disk has
none. The fetch then throws CONNECT_ML.MODEL_SUMMARY_LOST, and this package does what PySpark
does: rebuilds the summary from the training frame and asks again, once.
Except where there is no frame. A model from load/3 never saw the data, so nothing can be
rebuilt and the server's refusal stands. That is the one case the recovery must not fire on.
:ok = ML.delete(model)5. Pipelines and tuning are client code on both sides
Pipeline, CrossValidator and TrainValidationSplit have no Fit on the wire. PySpark loops
over the stages itself, cuts folds with rand(seed) and range filters, and writes the layout by
hand. So does this. The two are the same shape:
%Latu.ML.Pipeline{stages: [_assembler, _lr]} =
ML.pipeline([assembler, Classification.logistic_regression(max_iter: 5)])
grid = ML.param_grid(Classification.logistic_regression(), reg_param: [0.0, 0.1])
2 = length(grid)Because both sides are client code, the wire is not what has to match; the format is. A grid
entry here carries the uid of the operator it sets, since the thing searched is usually a pipeline
and a param belongs to one stage of it. dev/README.md has the interop dance.
Discovery: the registry, not the docs
PySpark's answer to "what params does this take" is its documentation. Here it is in the package, generated from PySpark's own classes and the server's own Scala, and queryable:
true = length(ML.operators()) > 100
[_ | _] = ML.operators(kind: :evaluator)
[_ | _] = ML.params(:logistic_regression)Things deliberately not here
Anything closure-shaped, because there is no way to ship an Elixir function to a Python worker:
predict_batch_udf, and xgboost.spark, whose training is Python code even on Connect. The
RDD-era spark.mllib. Building a model from parameters you hold: a model exists only by a fit or
a read, and there is no wire path for the other direction. OneVsRest, which shares nothing with
the validators but the word meta-algorithm. And the deprecated torch-based pyspark.ml.connect
package from 3.4 and 3.5.
Summarizer is reachable but not wrapped: its aggregate_metrics resolves, and reading one
metric out of the struct it answers with needs field access Latu does not build yet.
Where to go next
With Nx and Scholar is the other direction: getting numbers back onto
the BEAM, and where that stops working. docs/deviations.md is the spelling
table and the reasons.
Latu.disconnect(session, release: true)