Sitemap
Pinterest Engineering Blog

Inventive engineers building the first visual discovery engine, 300 billion ideas and counting.

Scaling Model Evaluation at Pinterest with Ray (Part 4)

10 min read5 days ago

--

Vishnu Arun | Tech Lead, Feature Importance Platform; Anderson Lam | Sr Software Engineer; Andrew Qiu | Machine Learning Engineer I; Helena Xu | Data Scientist I; Tianyou Wang | Data Scientist II

Introduction

In our multi-part blog series, we have discussed why we chose Ray as a last mile data processing framework, how we integrated Ray into our existing ML Training Infrastructure, and how we enabled efficient Batch Inference at scale. In this blog post, we will discuss how we were able to apply Ray to another critical part of our ML Infrastructure stack: Model Evaluation.

In the Ray Batch Inference Blog Post, we ended on a section on accumulators (counters that would allow us to calculate metrics like ROC-AUC and cross entropy). This post is where those accumulators pay off: a model evaluation platform that combines the strengths of Ray and Apache Spark to compute metrics efficiently at scale.

Model evaluation may appear to be a relatively straightforward step after inference or training. But as ML workflows scale, evaluation quickly becomes an infrastructure challenge. Systems that run many experiments in parallel, such as feature importance, must efficiently compute and aggregate metrics over large volumes of inference data, as shown in Diagram 1.

Later in this post, we’ll show how we amortize the metrics compute step, overlapping it with inference for minimal added runtime cost, cutting end-to-end evaluation time by up to 32% while scaling linearly as we run more experiments.

Press enter or click to view image in full size
Diagram 1: Depicts a feature importance system’s architecture. We illustrate a multi-head model with h heads and metrics calculated per head. In this type of workflow, metrics are calculated for each experiment and model head. Note: We omit a batched view of this data found in large scale production ML systems for simplicity.

Terminology

  • Feature Importance (FI): A technique for quantifying how much each input feature contributes to a model’s predictions. In this post, we will reference a perturbation-based method where we perturb one or more columns, run inference, and compare the results against an unperturbed dataset.
  • Experiment / baseline: One perturbed run vs. the unperturbed run.
  • Model head: One output of a multi-task model.
  • Batch: an arbitrary, compute-sized group of rows. This is the unit of data that one Ray task processes.
  • Bucket: a batch that is group-preserving. It contains one or more complete segments, and no segment may span buckets.
  • Segment: The semantic unit over which a group-wise metric is defined, such as a request.
  • Serde [serialization/ deserialization]: The process and cost of writing data out and reading it back.

Background

Traditionally, evaluation and inference are two separate stacks stitched together. Diagram 2 below contrasts that unfused pipeline against the fused pipeline we’ll build up over the rest of this post.

Press enter or click to view image in full size
Diagram 2: Unfused (top) vs. fused (bottom) inference-and-metrics compute. In the unfused lane, inference and metrics run as separate jobs connected by a serialize/persist/deserialize handoff; in the fused lane, metrics are computed per batch inside the Ray inference job via the shared object store. Red denotes the cross-stack handoff removed by fusion.

Concretely, this evaluation step is typically implemented as a Apache Spark job that computes metrics across one or more inference experiments. As shown in the diagram above, we previously used Ray inference to write predictions to S3, then have a follow-up compute job to reread all this data and compute the metrics.

For a FI run, this approach introduces two sources of overhead:

  • Cluster startup: Acquiring and warming up a cluster for the metric job.
  • Data movement: Writing predictions to S3 and reading them back to compute metrics.

Because inference is done in batches, and metrics typically depend on all of the inference results, the unfused approach can be used to calculate any metric. To save the overheads, we need to fuse the stages, but this also implies that we need to be able to calculate metrics on batches of rows. Therefore, we need a metric platform that treats decomposability as a first principle.

Metrics fall into three groups:

Press enter or click to view image in full size

Metric Contract

We define a common metric contract that captures each metric’s compute layout and provides a consistent interface across execution frameworks. We express this interface concretely as an Abstract Base Class.

from abc import ABC, abstractmethod

class ReducibleMetric(ABC):
@abstractmethod
def dataset_preprocessor(self, ds):
"""Prepare the dataset to satisfy the metric's data layout requirements."""
pass

@abstractmethod
def compute_partials(self, batch):
"""Compute a partial result from one batch"""
pass

@abstractmethod
def reduce_partials(self, partials):
"""Combine partial results into the final result."""
pass

Decomposable and holistic metrics can calculate partials from arbitrary batches because they do not require related rows to be colocated.

Group-wise metrics introduce an additional constraint. Before handing data to Ray, we organize complete segments into buckets. A bucket is therefore a group-preserving batch: it contains one or more complete segments, and no segment crosses a bucket boundary. This lets us independently tune bucket size for efficient execution without changing the semantic unit of the metric.

Common Metrics found in Recommender Systems

We will walk through three metrics, MAE, ROC-AUC, and Precision@K; one from each group above. We will use the following dataset to compute each metric.

For the examples below:

  • model_output contains the predictions, and label_continuous contains the target values.
  • label_binarized is derived as 1 if label_continuous ≥ 0.5 and 0 otherwise.
  • Batch A contains ReqA, and Batch B contains ReqB. This split lets us demonstrate batch-level computation and aggregation.
Press enter or click to view image in full size

MAE (Decomposable)
Mean Absolute Error measures the average absolute difference between predictions and their actual values.

MAE_b = (1/n_b) · Σ_{i ∈ batch b} |pred_i − label_continuous_i|

Unlike MSE/RMSE, MAE does not penalize large errors as heavily.

Per-row absolute errors:

Press enter or click to view image in full size

Global: Σ = 1.45, N = 8 → MAE = 0.18125.

Batch decomposition:
Batch A partial (Σ|err| = 0.65, n = 4)
Batch B partial (Σ|err| = 0.80, n = 4)
Merge (0.65 + 0.80) / (4 + 4) = 0.18125

class MAE(ReducibleMetric):
def dataset_preprocessor(self, ds):
# No dataset preparation needed. Rows are independent.
return ds

def compute_partials(self, batch):
# Emit summed absolute error and row counts from each batch.
error = np.abs(batch["model_output"] - batch["label_continuous"])
return error.sum(), len(error)

def reduce_partials(self, partials):
# Sum partials and divide once globally.
error, count = np.sum(partials, axis=0)
return error / count

ROC-AUC (Holistic)
ROC-AUC is a binary classification metric which measures the ability of a model to distinguish between classes across different decision thresholds.

Method 1 — Global Sort (what sklearn does)

Global Sort

We sort the rows by model_output in descending order, keeping each prediction paired with its corresponding binary label.

Cumulative Sum

After sorting, we calculate cumulative true-positive (TP) and false-positive (FP) counts and prepend 0 to both vectors.

Calculate the TPR and FPR

We calculate the TPR at each threshold by dividing the TP vector by the number of positive labels.

We calculate the FPR at each threshold by dividing the FP vector by the number of negative labels.

Calculate the Trapezoidal area under TPR/FPR

We calculate the AUC of TPR/FPR to get the final ROC_AUC value.

Get Pinterest Engineering’s stories in your inbox

Join Medium for free to get updates from this writer.

Sorting our eight rows by score:

Press enter or click to view image in full size

With 4 positives and 4 negatives, the area works out to AUC = 0.9375 (or 15/16 positive/negative pairs are ranked correctly).

We observe that ROC_AUC using this method is calculated over the entire dataset due to a global sort. We could break the dataset into batches, calculate ROC-AUC for each batch, and average the results. However, this does not produce the same result as a global ranking.

Consider the following calculation:

  • Batch A (ReqA) = 1.00
  • Batch B (ReqB) = 0.75
  • Average = 0.875, not 0.9375

Averaging a ranking number across batches is wrong, because AUC depends on how rows rank against all other rows, not just those in their batch.

We could force a real global sort in Apache Spark, but doing that across all metrics, model heads, and experiments is exactly the cross-stack cost we’re trying to avoid.

Method 2 — Confusion Matrix

Consider the standard method to approximate ROC_AUC.

Define thresholds

Pick a fixed set of thresholds in [0, 1].

Calculate Confusion Matrix

Calculate the confusion matrix (TP, FP, TN, FN) for each threshold

Calculate the final metric

Calculate the TPR/FPR and finally derive the AUC.

This method can be calculated batch-wise because the global confusion matrix can be calculated from the batch confusion matrices.

We can create the following confusion matrices per batch and then sum:

Press enter or click to view image in full size

From this approximate example, when we derive the area under the curve, we get 0.875. If we use more thresholds, for example [0.00, 1.00] with step size of 0.01, we would get 0.9375. Note: This does not mean that threshold-based aggregation generally produces the exact global AUC. In this toy example, a step size of 0.01 recovers the exact value because the prediction scores align with the threshold grid.

class ROCAUC(ReducibleMetric):
def dataset_preprocessor(self, ds):
# No dataset preparation needed. Rows are independent.
return ds

def compute_partials(self, batch):
# Emit confusion matrix across thresholds.
pred = batch["model_output"]
truth = batch["label_binarized"].astype(bool)

positive = pred[None, :] >= self.thresholds[:, None]
return np.stack([(positive & truth).sum(1), (positive & ~truth).sum(1),
(~positive & ~truth).sum(1), (~positive & truth).sum(1)],1)

def reduce_partials(self, partials):
# Sum confusion matrices and add ROC endpoints.
# Integrate in ascending FPR order.
tp, fp, tn, fn = np.sum(partials, 0).T
tpr, fpr = tp / (tp + fn), fp / (fp + tn)
return np.trapz(np.r_[0, tpr[::-1], 1], np.r_[0, fpr[::-1], 1])

Precision@K (Group-wise)
Precision@K is a common metric used for ranking systems. It measures the fraction of the top K ranked items that are relevant.

This metric is calculated within an individual segment of data, such as a session or request. A segment may be identified by a single key or a composite key.

The procedure for calculating Precision@K is as follows.

Group/Rank Dataset
Group the dataset by segment and sort by predicted score.

Window and Count
Select the top K items and count the relevant items within that window.

Calculate Metric
Divide the number of relevant items by K to calculate Precision@K for each segment. Average the per-segment scores to obtain the final result.

On our data with K = 2:

Press enter or click to view image in full size

Average = (1.00 + 0.50) / 2 = 0.75.

We observe this metric has a similar decomposition pattern. We can break it down into subproblems where we look at each segment, calculate the metric, then average final scores of all subproblems.

We have two problems when trying to scale this approach:

  1. The subproblem (batch_size) is tied to the size of each segment. This is inefficient as each executor may receive a small and variably sized amount of data.
  2. If we combine segments together, we must ensure that no segments span a bucket boundary.

Our goal is to keep every row for a given segment in the same bucket while ensuring that each bucket contains enough segments to saturate the compute resources.

More succinctly, we want all rows within each segment to be colocated and each bucket to be sufficiently large. Since we are operating at the global level, we can define this as an Apache Spark procedure. It keeps all rows for each segment together, then packs complete segments into buckets large enough to use each Ray worker efficiently without splitting a segment across buckets.

class PrecisionAtK(ReducibleMetric):
def dataset_preprocessor(self, ds):
# Colocate each segment and pack into buckets.
return bucket_complete_segments(
ds,
by="request_id",
)

def compute_partials(self, bucket):
# Rank within each segment and calculate top-K score.
scores = []
for segment in group_by(bucket, "request_id"):
ranking = np.argsort(
-segment["model_output"]
)
# Assume each segment contains at least K items.
top_k = ranking[:self.k]
relevant = segment["label_binarized"][top_k].sum()
scores.append(relevant / self.k)

return sum(scores), len(scores)

def reduce_partials(self, partials):
# Sum segment scores and divide by segment count.
score, count = np.sum(
partials,
axis=0,
)
return score / count

Fusing Metrics with Inference

Press enter or click to view image in full size
Diagram 3: Top Sequential Inference and Metric Compute. Bottom Fused Inference and Metrics Compute. Each cell is one unit of work indexed by bucket, head, and experiment: B = bucket, a group of rows that keeps each user/request segment colocated for group-wise metrics such as Hits@K; H = model head; E = perturbation experiment, with 0 denoting baseline. The sequential layout runs all inference before any metrics; the fused layout interleaves metric compute per bucket as inference completes. Internal inference batches are not shown.

In the upper figure in diagram 3, we show a typical Sequential Inference-Metrics multi-experiment pipeline. We run model inference over all the experiments, serialize, and persist those results along with the carryover labels. Note this inference dataset scales with the input data and the number of experiments. Next, we deserialize this data and compute metrics. GPU inference is typically more resource intensive than metrics computation. This design causes the CPU workers to sit idle during inference time.

The lower figure in diagram 3 illustrates a fused approach where we pipeline the metric computation once inference completes for each bucket. The shared object store minimizes serde overhead. This enables us to effectively overlap metric computation with inference, reducing total runtime.

Results

We benchmarked two setups against a Pin Ranking Model, sweeping across three dataset sizes (Small ≈ 30K, Medium ≈ 300K, Large ≈ 3M rows) and several experiment counts:

  • Unfused: Ray inference, then a separate Ray metric job.
  • Fused: Ray inference with metrics in the same pass.

When we gather metrics during inference, we do not see any meaningful runtime increases. The time measured here includes startup time (acquiring the cluster) and runtime (actual computation).

Press enter or click to view image in full size
Diagram 4: End-to-end wall-clock time (startup + runtime) for the unfused and fused pipelines across three dataset sizes at E = 100 experiments. Gray = unfused, indigo = fused, green = time saved. At E = 100, fusion cuts wall-clock time 25–32% at every size. This comes partly from removing a whole job and its fixed cluster-acquisition cost, and the saving holds as input grows.

Furthermore, we can see that runtime grows linearly with experiment count while startup stays flat.

Press enter or click to view image in full size
Diagram 5: Unfused versus fused pipeline runtime as experiment count scales on a Large dataset (3M rows). The unfused runtime includes the metrics stage immediately after inference; both runtimes exclude cluster-acquisition time.

As the number of experiments increases from 1 to 300 on the Large dataset, fused runtime grows approximately linearly (~43 min fixed + ~41s per experiment), while startup time remains roughly constant. The separate metric stage (~11s per experiment) is work that fusion absorbs through overlap with inference, keeping the marginal cost per experiment small and predictable.

What’s next

Other upcoming topics include:

  • Ray Tune at Pinterest
  • Improving the data loader performance in Ray
  • Fault-tolerant Ray Batch Inference and Training
  • Feature Importance on Ray

Acknowledgments

Related Pins: Shivin Thukral, Zilin Ren
AI Platform: Rubin Fergerson IV, Qingxian Lai
Data Platform Sciences: Darren Reger, Adam Obeng
Agent Platform: Tan Wang

--

Pinterest Engineering Blog
Pinterest Engineering Blog

Published in Pinterest Engineering Blog

Inventive engineers building the first visual discovery engine, 300 billion ideas and counting.