PySpark · lesson 8 of 11
Beginner
4Intermediate
4Advanced
3Also part of PySpark
6- + Spark Architecture: Driver, Executors, and Cluster Managers from Apache Spark
- + RDDs: The API Underneath DataFrames from Apache Spark
- + Reading the Spark UI to Diagnose a Slow Job from Apache Spark
- + Spark SQL and the Catalyst Optimizer from Apache Spark
- + Spark Memory Management and Debugging Failures from Apache Spark
- + Deploying Spark Jobs with spark-submit from Apache Spark
Also part of: Apache Spark
MLlib Basics: A Real Pipeline, Fit and Evaluated
Assemble feature vectors, chain them into a Pipeline with a classifier, and evaluate the fitted model with AUC and accuracy on held-out data.
By SkillByExample · Published
Every DataFrame operation in this track so far has been feature engineering, joins, and aggregation — the input to a model, never the model itself. MLlib is Spark’s own machine learning library, and it plugs directly into the same DataFrames.
The setup
pip install pyspark==3.5.4 numpy
numpy is a real dependency of pyspark.ml — the VectorAssembler and evaluator classes
import it directly, unlike the core pyspark.sql DataFrame API used elsewhere in this
track.
A Pipeline: assemble, then classify
Sixteen orders, each with an amount, days until shipped, and whether it was later returned.
from pyspark.sql import SparkSession
from pyspark.ml.feature import VectorAssembler
from pyspark.ml.classification import LogisticRegression
from pyspark.ml import Pipeline
from pyspark.ml.evaluation import BinaryClassificationEvaluator, MulticlassClassificationEvaluator
spark = SparkSession.builder.appName("mllib").master("local[2]").getOrCreate()
data = spark.createDataFrame([
(25.50, 3, 0), (12.00, 1, 0), (40.00, 14, 1), (8.75, 2, 0),
(63.20, 20, 1), (19.99, 4, 0), (55.00, 18, 1), (22.10, 5, 0),
(9.99, 1, 0), (31.40, 16, 1), (45.00, 15, 1), (15.25, 2, 0),
(60.00, 21, 1), (11.50, 1, 0), (28.00, 13, 1), (18.75, 3, 0),
], ["amount", "days_to_ship", "returned"])
train, test = data.randomSplit([0.75, 0.25], seed=42)
assembler = VectorAssembler(inputCols=["amount", "days_to_ship"], outputCol="features")
lr = LogisticRegression(featuresCol="features", labelCol="returned")
pipeline = Pipeline(stages=[assembler, lr])
model = pipeline.fit(train)
predictions = model.transform(test)
predictions.select("amount", "days_to_ship", "returned", "prediction", "probability").show(truncate=False)
$ python mllib_basics.py
+------+------------+--------+----------+-------------------------------------------+
|amount|days_to_ship|returned|prediction|probability |
+------+------------+--------+----------+-------------------------------------------+
|19.99 |4 |0 |0.0 |[0.9999999991199242,8.800757900218059E-10] |
|55.0 |18 |1 |1.0 |[2.964294904958629E-19,1.0] |
|9.99 |1 |0 |0.0 |[0.9999999999999993,6.661338147750939E-16] |
|31.4 |16 |1 |1.0 |[1.2453099987548877E-13,0.9999999999998754]|
|45.0 |15 |1 |1.0 |[4.061595234027321E-13,0.9999999999995939] |
|60.0 |21 |1 |1.0 |[6.131395592202325E-25,1.0] |
+------+------------+--------+----------+-------------------------------------------+
VectorAssembler runs first, folding amount and days_to_ship into one features
column — the shape every MLlib algorithm expects. pipeline.fit(train) then runs both
stages: assemble the training data, fit LogisticRegression on the result. Calling
.transform(test) on the returned PipelineModel reapplies both stages to data the model
never trained on — the same VectorAssembler configuration, no risk of the reader
forgetting to repeat it by hand.
Evaluating the fitted model
auc = BinaryClassificationEvaluator(labelCol="returned").evaluate(predictions)
acc = MulticlassClassificationEvaluator(labelCol="returned", metricName="accuracy").evaluate(predictions)
print(f"AUC: {auc:.4f}")
print(f"Accuracy: {acc:.4f}")
spark.stop()
AUC: 1.0000
Accuracy: 1.0000
Both evaluators take the same predictions DataFrame .transform() produced — no
separate scoring step, no converting to numpy arrays first. A perfect score here is real,
not a sign of a broken evaluator: days_to_ship cleanly separates the two classes in this
16-row dataset (every returned order shipped in 13+ days), which is exactly the kind of
result that should make you suspicious of a real dataset scoring this well and check for
label leakage before trusting it.
Practice
1. Print the fitted model's coefficients and intercept.
lr_model = model.stages[-1]
print(f"Coefficients: {lr_model.coefficients}")
print(f"Intercept: {lr_model.intercept}")
Coefficients: [0.20834396100674674,4.015669187013517]
Intercept: -41.078485652812056
model.stages holds every fitted stage in order — [-1] is the last one, the fitted
LogisticRegressionModel. The days_to_ship coefficient (4.02) dwarfs amount’s (0.21),
confirming what the perfect-separation result already hinted: shipping delay is doing
nearly all the predictive work here. Re-run this fit and the exact numbers shift each
time — perfectly separable data has no unique logistic-regression optimum, so the
iterative solver’s stopping point (not the underlying signal) moves. The lopsided ratio
between the two coefficients is the stable part; the absolute values are not.
2. Add a third feature column and refit the Pipeline without changing the LogisticRegression stage.
data2 = data.withColumn("amount_per_day", data.amount / data.days_to_ship)
assembler2 = VectorAssembler(inputCols=["amount", "days_to_ship", "amount_per_day"], outputCol="features")
pipeline2 = Pipeline(stages=[assembler2, lr])
model2 = pipeline2.fit(data2.randomSplit([0.75, 0.25], seed=42)[0])
print(model2.stages[-1].coefficients)
[0.2726122504191249,3.6939078077778835,-0.9538249074969803]
Only assembler2’s inputCols changed — lr is reused unmodified, since a Pipeline
stage just needs a features column of the right shape, not a fixed number of source
columns.
3. Evaluate with metricName="f1" instead of accuracy.
f1 = MulticlassClassificationEvaluator(labelCol="returned", metricName="f1").evaluate(predictions)
print(f"F1: {f1:.4f}")
F1: 1.0000
Same perfect score, for the same reason as the AUC and accuracy above — f1 only
diverges from accuracy when there is a real precision/recall tradeoff to weigh, which a
perfectly separable dataset does not have.