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, anddepth(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:
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:
< 18tochild, decade-ish bands up toage 55-64, thenelderly; 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
Internationaltag, 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(pertags.json/taxonomy.py).survey_affinitywrites that key correctly, but_demographic_affinitywritesplace_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.