Skip to content

Signals

recsys/signals/ turns raw events into the user model. Both modules are pure, no IO, fully unit/property tested.

engagement.py, continuous strength

Three public functions, no class:

Function Signature (abridged) Does
estimate_reading_time (word_count, has_image, cfg) -> float Seconds to consume content.
engagement_strength (*, dwell_seconds, est_reading_time, end_reason, visits, survey_rating, cfg) -> float Continuous blend in ~[-1,1].
classify_outcome (strength, cfg) -> Outcome Threshold -> positive / negative / neutral.
dwell_ratio = min(dwell / est_reading_time, dwell_cap) / dwell_cap            # [0,1]
completion  = {next_button:1.0, link:0.6, close_button:0.0, abandon:-0.5}
revisit     = 1 - exp(-visits / 2)
survey      = (rating - 3) / 2                                                # 1..5 -> [-1,1]
strength    = wd·dwell_ratio + wc·completion + wr·revisit + ws·survey

Weights wd, wc, wr, ws come from RecConfig.engagement. This replaces the legacy binary dwell >= estimate with a graded signal, partial reads, abandons, and revisits all move the needle.

Property tested

Dwell monotonicity (more dwell ⇒ not-lower strength), abandon ⇒ negative contribution, survey extremes map to ±1.

signal_builder.py, fold into UserSignals

flowchart TD
    ev["Sequence[InteractionEvent]"] --> agg["aggregate_views()"]
    agg --> va["dict[content_id -> ViewAggregate]<br/>(dwell paired, visits, end_reason,<br/>last_ts, survey_rating)"]
    va --> loop["per content"]
    loop --> est["estimate_reading_time"]
    loop --> str["engagement_strength"]
    str --> cls["classify_outcome"]
    str --> dec["recency decay<br/>w = strength · 0.5^(age/half_life)"]
    cls -->|positive| pos["positives[cid] = w"]
    cls -->|negative| neg["negatives[cid] = w"]
    imp["impressions never viewed"] --> sn["soft negatives<br/>w = soft_neg · decay"]
    str --> taff["tag_affinity += tag.weight · w"]
    pos --> tv["taste_vector =<br/>L2-norm centroid of positive vecs"]
    demo["demographics"] --> daff["person_who:* affinity"]
    pos & neg & sn & taff & tv & daff --> us["UserSignals"]

ViewAggregate

All views of one content folded together, content_id, dwell_seconds, visits, end_reason, last_ts, survey_rating. aggregate_views is robust to asynchronous webhook events (separate START/END events) and to sources that already carry explicit dwell_seconds.

build_user_signals

build_user_signals(*, user_id, events, contents, vectors, now, cfg, demographics=None)
    -> UserSignals

Key behaviors:

  • Recency decay with half_life_days: recent engagement weighs more.
  • Soft negatives: content impressed (shown) but never viewed becomes a weak negative, scaled by soft_negative_weight x decay. Teaches the model what the user skipped.
  • Tag affinity / aversion: per-theme interest and dislike in the content taxonomy, built from engagement, survey, and demographics. See Tag affinity and aversion below.
  • Taste vector: L2-normalized centroid of positively-engaged content vectors; the query vector for semantic recall.
  • Engagement summary (behavior): per-visitor stats that ground the persona explanation, n_views, n_positive, n_negative, avg_dwell_ratio, completion_rate, revisit_rate, and depth (positive / views). See Explainability.

Tag affinity and aversion

tag_affinity is a {facet:label -> weight} map of how strongly the visitor leans toward each taxonomy tag; tag_aversion is the mirror for disliked themes. They are the structured, explainable half of the model and feed the score_tag and score_aversion scorers directly. Three sources are summed, then the keys are case-folded and the whole map is max-normalized to [0,1].

1. Engagement (the dominant source). For every positively-engaged content, each of its tags accumulates:

tag_affinity[tag.key] += positives[cid] * tag.weight
                       = (max(strength, 0) * decay) * tag.weight

A tag grows with how strongly and how recently the visitor engaged with content carrying it (strength x decay), scaled by the expert tag confidence tag.weight. Aversion is symmetric over negatively-engaged content: tag_aversion[tag.key] += negatives[cid] * tag.weight.

2. Survey (explicit preferences). The personalization questions carry a canonical taxonomy label as their answer, so they map onto tags 1:1 at the strong weight 1.0:

Question Facet Weight
q:personalization_theme theme_what 1.0
q:personalization_interest theme_how.type_of_stores 1.0
q:personalization_area place_where.camp_areas 1.0

Values are canonicalized first (separators to spaces, plus an alias map, e.g. "forced labour" to Forced Labor) so survey vocabulary still matches content tags. score_tag compares case-insensitively, so casing is safe.

3. Demographics. See the next section.

Fold and normalize. Keys are lower-cased and merged (collapsing case/spelling variants), then divided by the max so the single strongest tag becomes 1.0. Engagement accumulates without bound while survey and demographic seeds are small fixed weights, so the explicit seeds dominate a fresh profile and then naturally wash out as real engagement grows. That is by design: the persona is a starting prior, behavior overrides it.

Survey and demographic affinity (cold start)

Stable visitor attributes are mapped onto the same person_who / place_where facets the content is tagged with, so score_tag does persona-to-content matching with no special casing. Two entry points feed this:

  • survey / identify events through survey_affinity() (survey.py),
  • a demographics provider (Postgres or static) through _demographic_affinity() (signal_builder.py).
Attribute Tag facet Example label Weight
age person_who.age_group age 25-34, child, elderly 0.5
gender person_who.gender_and_age Male, female, non-binary 0.3
nationality person_who.city_village_country From: Netherlands 0.4
nationality (non-core) person_who.city_village_country International (rollup) 0.3
province (NL) person_who.province_netherlands (see note) Zuid-Holland 0.5
  • Age buckets: < 18 to child, decade-ish bands up to age 55-64, then elderly; the survey path adds finer senior bands (age 65-74 ... age 85+).
  • Nationality rollup: any origin outside the core set (Netherlands, Germany, Poland) also adds the International tag, so visitors from less-represented countries still match the complement of country-tagged content.
  • Note (province facet mismatch): the two seed paths disagree on the province facet. Content province tags live under person_who.province_netherlands (per tags.json / taxonomy.py). survey_affinity writes that key correctly, but _demographic_affinity writes place_where.province_netherlands, so a province seeded via the demographics provider (rather than the survey) matches no content until reconciled.

These are pre-normalization seeds: they give a brand-new visitor a non-empty tag_affinity so score_tag contributes on the very first request, before any engagement exists.

This whole fold is the brain. The online updater rebuilds it from the event buffer each refresh so there is exactly one definition of "the user model", see Orchestration and the serving model.


Full auto-generated reference

Code reference -> Recsys package.