BoscaRecommendation Models
Chapter 3 of 7

Build the datasets

In trainer/datasets.py, prepare_datasets joins observations to eligible content: it matches rows with the same article ID and puts their columns together. The result is a table named merged. Each row now has a reader, an article, a response label, and known article features. This chapter explains the two variables that are easy to confuse: retrieval_positive is a column of decisions, while retrieval_mask is the array copied from that column to select rows.

Put named inputs into interaction_features

The function creates a Python dictionary. Each key names an input or training value. Each value is a NumPy array: an ordered collection with one entry per row of merged. Position 0 in every array refers to the same first observation, position 1 to the same second observation, and so on. This shared order is why the masking code below matters.

interaction_features = {
    "user_id": merged["user_id"].to_numpy(),
    "content_id": merged["content_id"].to_numpy(),
    "content_type": merged["content_type"].to_numpy(),
    "language_tag": merged["language_tag"].to_numpy(),
    "label": merged["label"].to_numpy(dtype=np.float32),
    "sample_weight": merged["sample_weight"].to_numpy(dtype=np.float32),
    "source_id": merged["source_id"].to_numpy(dtype=object),
    "behavior_features": behavior_features,
}

merged['language_tag'] selects a table column. .to_numpy() turns that column into an array. dtype=np.float32 makes labels and weights numbers TensorFlow can use. The actual function adds optional editorial type, category, label, collection, semantic embedding, reader signal, and affinity fields when those features exist.

source_id is the article Sam was viewing when a recommendation caused this response. It is empty unless the response was attributed to a request in the context being trained, and the source is a different eligible item. behavior_features holds four numbers about population behavior: co-engagement, cohort co-engagement, learned-neighbor affinity, and rating affinity. Co-engagement, for example, records whether readers tended to engage with both a source article and a candidate.

These numbers help ranking score a pair; label records the response to learn from.

Where the behavior numbers come from

Each run that trains a personalized model reads the recommender-behavior analytics query. It returns the co-engagement edges and cohort memberships that Bosca's recommendation strategies computed. capture_snapshot combines those rows with the latest explicit ratings and the previous model's nearest-neighbor predictions into a behavior snapshot. It records when the snapshot was taken (available_at) and saves it with the model it trains as behavior.json.

One run uses two snapshots for different jobs:

SnapshotUsed forWhy
The one built during this runThe ranking model's behavior_model when it scores reader-item pairs, and the candidate filter for co_engagedRanking uses the behavior captured for this model version. The embedding-only similar and similar_users functions do not use this behavior term.
The one saved with the previous completed modelbehavior_features on training rows recorded after that snapshot was taken; older rows get zerosTraining must see only numbers that existed when the recommendation was shown.

Why not use this run's query for training rows too? Suppose Sam read article-9, was shown article-12, and completed it. That completion may have raised the article-9 → article-12 co-engagement score the query returns today. Giving the row today's score would put part of the answer into the inputs. Ranking would learn to trust co-engagement too much, because at serving time no score can include a response that has not happened yet. This is called leakage.

The query cannot rebuild an older value. A strategy refresh deletes its edges and writes new ones, so the tables hold only the current scores. The query's created <= asOf filter only checks whether today's row existed by that time; it cannot return the score the row had then. Cohort memberships have no time filter at all. A saved snapshot keeps the values that existed when it was taken, and available_at shows which later rows may use it.

So this run's behavior snapshot affects the behavior term of the new model's ranking scores. It is also available as training input for the next version. What ranking learns in this run is how much to trust each of the four behavior signals: the trainable behavior_gain. It learns from the outcomes of training rows that could use the previous snapshot's values, then applies the gain to this run's captured values at serving time. Item-to-item and reader-to-reader embedding similarity do not use this behavior term.

Work through one behavior term by hand

For a finished model version, both the captured values and learned gains stay fixed while it serves. During training of a new version, the trainer can change the gain for each signal. score_pairs adds this term when it ranks a reader-item pair:

behavior term = sum over the four signals of:
                captured value × context weight × learned gain
PartWhere it comes fromDifferent for each pair?
Captured valueThis run's behavior snapshot; its calculation depends on the signal, as shown belowYes: it can depend on the reader, source item, and candidate item
Context weightThe context's saved coEngagement, cohortCoEngagement, learnedNeighbor, or rating setting (default 1.0)No: one per signal
Learned gainbehavior_gain, fitted using outcomes on training rows with values from the previous snapshotNo: one per signal

The four captured values are prepared in different ways:

SignalHow its value is prepared
Co-engagementCount of distinct readers who engaged with both items in the last 90 days, scaled with count / (count + 10).
Cohort co-engagementThe same count scale, using the strongest matching cohort edge for this reader.
Learned-neighborWhen a previous model is available, its predictions are combined across nearby readers, then scaled with score / (1 + score).
RatingEarlier ratings are changed to signed values (2 × rating − 1) and combined according to how similar the rated items are to this candidate. The result can be negative.

Suppose today's query says 15 readers engaged with both article-9 and article-12. The fresh co-engagement value is 15 / (15 + 10) = 0.6. Suppose training found that past co-engagement predicted responses only weakly and learned a gain of 0.4. With the default context weight, this pair's co-engagement contribution is 0.6 × 1.0 × 0.4 = 0.24. A pair that 40 readers engaged with both gets 40 / 50 = 0.8, and 0.8 × 1.0 × 0.4 = 0.32.

The gain multiplies every pair's value by the same amount; it does not adjust individual pairs. Training asks, “When co-engagement was high in the past, did readers actually respond?” Serving then applies that answer to the current co-engagement numbers. Because the count / (count + 10) scale is fixed, a value of 0.6 means the same thing in every snapshot, so a gain learned from an older snapshot still applies to newer numbers.

Cohort co-engagement, learned-neighbor, and rating signals count only for readers in the model's vocabulary. Anonymous or unknown readers get only population co-engagement. The behavior term is also only one part of the score: the content baseline and the personalized tower match are added separately, as the training chapter shows.

The learned-neighbor signal is the one that cannot come from SQL. Readers “near” Sam are the nearest reader vectors in a trained model, and the model being trained has no final vectors until after training uses the snapshot. The previous model's similar_users and serving_default functions supply those neighbors and what they would be recommended.

On a first run, or when the previous model's files cannot be found, there is no previous snapshot. Every training row's behavior numbers are then zero and there is no learned-neighbor signal. With only zeros to learn from, behavior_gain keeps its starting value of 1. Serving then uses this run's query results with the context's saved behavior weights unchanged.

Here, features means the dictionary of values passed to model code. Its contents include label and sample_weight because training needs them, even though the towers do not look at those two values when calculating a recommendation. The towers pick out the inputs they use.

See the three example rows before masking

Using the observations from the previous chapter, assume the rows are in this order:

Row positioncontent_idlabelretrieval_positiveOne feature: language_tag
0article-91.0Trueen
1article-100.0Falseen
2article-110.25Falseen

Every array in interaction_features has three entries in that same order. If one array were filtered without the others, a reader ID could be paired with the wrong content ID. The code therefore applies one mask to every field.

Copy the decision column into a Boolean mask

retrieval_mask = merged["retrieval_positive"].to_numpy(dtype=bool)

For the three rows above, retrieval_mask is [True, False, False]. A mask is simply an array of yes/no values: keep position 0, drop positions 1 and 2. dtype=bool ensures the values are Booleans rather than strings or numbers.

retrieval_features = {
    key: tf.ragged.boolean_mask(value, retrieval_mask) if isinstance(value, tf.RaggedTensor) else value[retrieval_mask]
    for key, value in interaction_features.items()
}

Read this comprehension as a loop: “For every key and value in interaction_features, put a filtered value under the same key in retrieval_features.” Ordinary arrays use value[retrieval_mask]. A ragged tensor stores variable-length lists, such as different numbers of collections per item; it needs TensorFlow's tf.ragged.boolean_mask instead.

After this line, retrieval_features['content_id'] contains only article-9. Its reader ID, language, label, and weight have been filtered to that same row.

Make the two learning datasets

retrieval_features["sample_weight"] = retrieval_features["sample_weight"] * retrieval_features["label"]
retrieval_dataset = tf.data.Dataset.from_tensor_slices(retrieval_features)
ranking_dataset = tf.data.Dataset.from_tensor_slices(interaction_features)

A tf.data.Dataset is TensorFlow's stream of training examples. from_tensor_slices cuts each array into rows, so one dataset element holds the same position from every key. Keras's fit reads a dataset in batches and can cache or shuffle it along the way.

LineWhat it changesWhy
Multiply retrieval weight by labelA weaker positive gets less retrieval influence than a stronger one with the same confidence and age.Positive engagement has degrees, even after the yes/no selection.
from_tensor_slices(retrieval_features)Makes one TensorFlow example per selected positive row.The retrieval task should learn from positive reader-item pairs.
from_tensor_slices(interaction_features)Makes one example per labeled row, including zero and weak labels.The ranking task needs both favorable and unfavorable evidence.

prepare_datasets also returns content_dataset, one row per candidate item, and user_dataset, one row per eligible profile. The user dataset includes profiles with no interaction history, so saved serving lookups can use their available signals. A vocabulary dictionary accompanies the datasets and lists known IDs, categories, languages, and other values.

One more gate before fitting

run_training always builds the content artifact when content exists. It starts the personalized path only after eligible interactions plus feedback reach min_interactions (100 by default). _train_personalized then counts the prepared positive rows in retrieval_dataset and skips the personalized fit if fewer than the same minimum remain. A pile of dismissals can pass the raw count and still fail the positive-row check. The content artifact can still be refreshed.

Check: With mask [True, False, False], how many retrieval examples and ranking examples do we have? One retrieval example; three ranking examples.