RFCs

Negative feedback and regret

Which recommended exposures most likely led to this unsubscribe, hide or return, and which surfaces and items should a guardrail demote?

RFC 0008RecommendationsIn designWritten 2026-10-20

Summary

We rank the recent exposures that most likely led to a user's regret event, such as an unsubscribe, a hide or a return. From the same learned links, we give you a regret score for candidate items and surfaces before you serve them.

Recommendation teams optimize clicks and watch time, but some exposures backfire. When an unsubscribe or a "not interested" arrives, they want the likely culprits: a burst of pushes, a clickbait item, an off-taste autoplay. They also want a guardrail score that ranking or notification logic can use to demote or cap risky candidates.

Model

HeapDBRecommendations
EntityOne user
EventsServed items (impression) and sent notifications (notification)
Featuressurface:autoplay, surface:feed, surface:search, surface:push, surface:email, category:<x>, trait:clickbait, trait:repeat, trait:off_taste, trait:new_creator, notif:burst (third or later push within one hour)
Weight (dose)1.0 per exposure; watch time or notification count could be used later
Effects and kernelsunsubscribe: LogNormalKernel(median=4 * H, sigma=1.0), one-shot (repeatable=False). return_or_hide: ExponentialDecay(half_life=15 * M), only impression events can cause it (cause_types)
Leak0.05 for both effects: users also unsubscribe for reasons outside the stream
Rulessurface:search x0.3: the user asked for that item, so it is rarely regretted

Priors are skeptical, Beta(1, 9) with mean 0.1: most exposures cause no regret.

Learning

  • Feedback: an explicit reason, such as "not interested because I've seen this" or an unsubscribe survey answer, names the exposure. That exposure is confirmed with feedback.
  • Observe: regret events without a reason go to observe. Each exposure in the kernel window gets soft credit in proportion to its score; the leak takes the rest.
  • Settle: settle adds negative evidence for exposures whose kernel window passed with no regret. For unsubscribe the window is the log-normal kernel's support, about nine days. For return_or_hide it is ten half-lives, 2.5 hours. Exposures after a user's unsubscribe are skipped, since it is one-shot.
  • Priors: weak and skeptical by default. A team can seed known risks, for example a stronger prior on notif:burst, and let data move it.

Sidecar architecture

The system of record stays where it is: the serving log, the notification service and the feedback or returns database. Exposures and regret events reach HeapDB through the existing event stream or an outbox table, keyed by user id. HeapDB writes back two things: per-incident explanations for the trust and safety or growth team, and a periodic table of feature_risk scores that ranking reads as a guardrail feature. HeapDB holds no state that cannot be rebuilt by replaying the source logs.

Worked example

The script simulates 900 users over 21 days. Push bursts and clickbait items drive unsubscribes. Autoplay is the most common exposure but harmless, and heavy users get more pushes and more of everything else.

Output of 0008-negative-feedback-and-regret.py

SCENARIO
  187,250 exposures for 900 users (261 heavy) over 21 days
  unsubscribe: 346 occurrences, 90 with an explicit reason (feedback), 256 observed
  return_or_hide: 3423 occurrences, 1400 with an explicit reason (feedback), 2023 observed
  settle: exposures whose window passed without regret: 108,023 (unsubscribe), 142,574 (return_or_hide)

ONE UNSUBSCRIBE, EXPLAINED
Top causes of 'unsubscribe' for user-0727 at t=60118.7:
  1. #151210 notification@56154.6 [category:cooking,notif:burst,surface:push,trait:clickbait,trait:new_creator] w=1 score=0.3161
      lag 1.10h -> kernel 0.959 x weight 1 x link 0.330 [noisy-OR of surface:push=0.08 (a=1.35, b=14.62), notif:burst=0.08 (a=1.19, b=14.36), trait:new_creator=0.08 (a=1.19, b=14.39), trait:clickbait=0.07 (a=1.15, b=14.20), category:cooking=0.07 (a=1.10, b=14.25)]
  2. #151208 notification@54202.1 [category:news,surface:push,trait:new_creator] w=1 score=0.2171
      lag 1.64h -> kernel 0.994 x weight 1 x link 0.218 [noisy-OR of surface:push=0.08 (a=1.35, b=14.62), trait:new_creator=0.08 (a=1.19, b=14.39), category:news=0.08 (a=1.17, b=14.27)]
  3. #151211 notification@56192.9 [category:news,notif:burst,surface:push] w=1 score=0.2089
      lag 1.09h -> kernel 0.956 x weight 1 x link 0.218 [noisy-OR of surface:push=0.08 (a=1.35, b=14.62), notif:burst=0.08 (a=1.19, b=14.36), category:news=0.08 (a=1.17, b=14.27)]
  [segments: 1 scanned / 0 pruned / 1 in window; events scored: 4]

ACROSS ALL USERS: links of 'unsubscribe' (top 8)
  feature                  mean          90% CI   lift  exposures
  notif:burst             0.011 [0.007, 0.015]   4.36      2,642
  trait:clickbait         0.006 [0.004, 0.008]   2.46     11,229
  surface:push            0.004 [0.003, 0.005]   1.59     15,857
  category:cooking        0.002 [0.001, 0.002]   0.69     31,580
  surface:search          0.002 [0.001, 0.003]   0.69     13,077
  category:finance        0.002 [0.001, 0.002]   0.66     31,115
  category:gaming         0.002 [0.001, 0.002]   0.64     30,959
  trait:new_creator       0.002 [0.001, 0.002]   0.63     22,658

GUARDRAIL TABLE: feature_risk of candidate exposures (population links)
  candidate                unsubscribe    hide  regret
  push in a burst                0.016   0.000   0.016
  clickbait feed item            0.009   0.047   0.055
  single push                    0.005   0.000   0.005
  new-creator feed item          0.004   0.029   0.033
  off-taste autoplay             0.004   0.065   0.069
  autoplay item                  0.003   0.022   0.024
  email digest                   0.003   0.000   0.003
  sorted by unsubscribe risk; regret = 1 - (1 - unsubscribe) x (1 - hide);
  hide is 0 for notifications (only impressions can be hidden)

CHECK
  unsubscribe risk, 10 surface/trait/notif features: #1 notif:burst 0.011, #2 trait:clickbait 0.006, #3 surface:push 0.004
  most common exposure surface:autoplay ranks #5 of those 10; trait:new_creator (no true effect) ranks #4, lift 0.63
  seeded causes notif:burst + trait:clickbait in top 2: yes
Show the full script (242 lines)
"""Negative feedback and regret: which recommended exposures go with unsubscribes and hides?

Simulates a content recommender over three weeks. Each user (the entity) is served items on
autoplay, feed and search, and gets push and email notifications; every one is an event
with features surface:<s>, category:<c>, optional trait:<clickbait|repeat|off_taste|new_creator>,
and notif:burst on a push that is the 3rd or later push within one hour.

Two regret effects:
  unsubscribe     one-shot (repeatable=False), lag minutes to days
  return_or_hide  repeatable, lag of minutes ("not interested", hide)

Hidden ground truth (per exposure, noisy-OR):
  unsubscribe:    notif:burst 0.05, trait:clickbait 0.02   (plus a small background rate)
  return_or_hide: trait:off_taste 0.15, trait:clickbait 0.08, trait:repeat 0.04
Distractor: surface:autoplay is the most common exposure overall and causes nothing.
Confounder: heavy users get more pushes (so more bursts) and more of everything else.
New-creator items are promoted through push, so trait:new_creator rides along with bursts.

Learning: a regret event with an explicit reason ("not interested because...", an unsubscribe
survey) is feedback on the exposure it names; the rest are observe(); exposures whose window
passes without regret are settled as negative evidence.

Run:
    pip install heapdb
    python 0008-negative-feedback-and-regret.py
"""

from __future__ import annotations

import math
import random
from collections import Counter

from heapdb import ExponentialDecay, HeapDB, LogNormalKernel, format_result

M, H, D = 60.0, 3600.0, 86400.0
SEED = 8
N_USERS = 900
DAYS = 21

TRUTH = {
    "unsubscribe": {"notif:burst": 0.05, "trait:clickbait": 0.02},
    "return_or_hide": {"trait:off_taste": 0.15, "trait:clickbait": 0.08, "trait:repeat": 0.04},
}
SEEDED = ("notif:burst", "trait:clickbait")
BACKGROUND_UNSUB = 0.03          # per user over the period, for reasons outside the stream
LABELED = {"unsubscribe": 0.3, "return_or_hide": 0.4}   # share with an explicit reason
CATEGORIES = ["news", "sports", "music", "cooking", "gaming", "finance"]


def pick_traits(rng: random.Random, push: bool) -> set[str]:
    t = set()
    if rng.random() < 0.06:
        t.add("trait:clickbait")
    if rng.random() < 0.10:
        t.add("trait:repeat")
    if rng.random() < 0.08:
        t.add("trait:off_taste")
    if rng.random() < (0.35 if push else 0.10):   # new creators are promoted via push
        t.add("trait:new_creator")
    return t


def simulate(rng: random.Random):
    """Return (events, outcomes). events: (user, t, type, features, key);
    outcomes: (t, user, effect, key of the triggering exposure or None)."""
    events, outcomes = [], []
    n_heavy = 0
    for u in range(N_USERS):
        user = f"user-{u:04d}"
        heavy = rng.random() < 0.3
        n_heavy += heavy
        evs = []
        for day in range(DAYS):
            # Sessions: heavy users come back more often.
            for _ in range(rng.choice([2, 3, 4]) if heavy else rng.choice([0, 0, 1, 1, 2])):
                ts = day * D + rng.uniform(7, 23) * H
                for j in range(rng.randint(4, 8)):
                    r = rng.random()
                    surface = "autoplay" if r < 0.5 else "feed" if r < 0.92 else "search"
                    feats = {f"surface:{surface}", f"category:{rng.choice(CATEGORIES)}"}
                    feats |= pick_traits(rng, False)
                    evs.append((ts + j * rng.uniform(2, 6) * M, "impression", feats))
            # Pushes: heavy users get more, and sometimes a burst of 3-4 within an hour.
            n_push = rng.choice([1, 2, 2, 3]) if heavy else rng.choice([0, 0, 1])
            burst = rng.random() < (0.25 if heavy else 0.06)
            base = day * D + rng.uniform(9, 20) * H
            times = sorted(base + rng.uniform(0, 8) * H for _ in range(n_push))
            if burst:
                times += [base + k * rng.uniform(8, 18) * M for k in range(rng.choice([3, 4]))]
            times.sort()
            for i, t in enumerate(times):
                feats = {"surface:push", f"category:{rng.choice(CATEGORIES)}"}
                feats |= pick_traits(rng, True)
                if sum(1 for t2 in times[: i + 1] if t - t2 <= H) >= 3:
                    feats.add("notif:burst")
                evs.append((t, "notification", feats))
            if rng.random() < 0.5:
                feats = {"surface:email", f"category:{rng.choice(CATEGORIES)}"}
                evs.append((day * D + 7 * H, "notification", feats | pick_traits(rng, False)))
        evs.sort(key=lambda e: e[0])

        # Outcomes from the hidden truth.
        unsub_t, unsub_key = math.inf, None
        for i, (t, typ, feats) in enumerate(evs):
            for f in sorted(feats):
                if rng.random() < TRUTH["unsubscribe"].get(f, 0.0):
                    when = t + math.exp(rng.gauss(math.log(4 * H), 1.0))
                    if when < unsub_t:
                        unsub_t, unsub_key = when, i
            if typ == "impression":
                for f in sorted(feats):
                    if rng.random() < TRUTH["return_or_hide"].get(f, 0.0):
                        outcomes.append((t + rng.expovariate(math.log(2) / (15 * M)), user,
                                         "return_or_hide", (user, i)))
                        break
        if rng.random() < BACKGROUND_UNSUB:
            when = rng.uniform(0, DAYS * D)
            if when < unsub_t:
                unsub_t, unsub_key = when, None
        keyed = list(enumerate(evs))
        if unsub_t < math.inf:
            outcomes.append((unsub_t, user, "unsubscribe",
                             None if unsub_key is None else (user, unsub_key)))
            # No more pushes or emails once a user has unsubscribed.
            keyed = [(k, e) for k, e in keyed if e[1] != "notification" or e[0] <= unsub_t]
        events += [(user, t, typ, feats, (user, k)) for k, (t, typ, feats) in keyed]
    outcomes = [o for o in outcomes if o[0] <= DAYS * D + 2 * D]
    outcomes.sort(key=lambda o: (o[0], o[1], o[2]))
    return events, outcomes, n_heavy


def make_db() -> HeapDB:
    db = HeapDB(bucket_seconds=6 * H)
    db.register_effect("unsubscribe", LogNormalKernel(median=4 * H, sigma=1.0),
                       default_prior=(1, 9), leak=0.05, repeatable=False)
    db.register_effect("return_or_hide", ExponentialDecay(half_life=15 * M),
                       default_prior=(1, 9), leak=0.05, cause_types={"impression"})
    for eff in db.effects:
        # The user asked for it: search results are rarely regretted.
        db.add_rule(eff, "search-low", lambda e: "surface:search" in e.features, 0.3)
    return db


def main() -> None:
    rng = random.Random(SEED)
    events, outcomes, n_heavy = simulate(rng)
    db = make_db()
    ids = {}
    for user, t, typ, feats, key in events:
        ids[key] = db.insert(user, t, typ, feats).id

    n_fb = Counter()
    for t, user, eff, key in outcomes:
        if key is not None and key in ids and rng.random() < LABELED[eff]:
            # Explicit reason: confirm the exposure it names, then record the occurrence
            # with an infinite leak (no soft credit) so settle() knows the effect happened.
            db.feedback(user, eff, at_time=t, confirmed_event_id=ids[key])
            db.observe(user, eff, t, leak=math.inf)
            n_fb[eff] += 1
        else:
            db.observe(user, eff, t)
    settled = db.settle(DAYS * D + 30 * D)

    n_out = Counter(o[2] for o in outcomes)
    exposure = Counter(f for e in events for f in e[3])
    print("SCENARIO")
    print(f"  {len(events):,} exposures for {N_USERS} users ({n_heavy} heavy) over {DAYS} days")
    for eff in ("unsubscribe", "return_or_hide"):
        print(f"  {eff}: {n_out[eff]} occurrences, {n_fb[eff]} with an explicit reason (feedback), "
              f"{n_out[eff] - n_fb[eff]} observed")
    per = settled.per_effect
    print(f"  settle: exposures whose window passed without regret: {per['unsubscribe']:,} "
          f"(unsubscribe), {per['return_or_hide']:,} (return_or_hide)")

    # One unsubscribe, explained: the first whose top suspect is a burst push.
    pick = None
    for t, user, eff, key in outcomes:
        if eff != "unsubscribe":
            continue
        res = db.top_causes(user, "unsubscribe", t, k=3, explain=True)
        if res.top is not None and "notif:burst" in res.top.event.features \
                and "trait:new_creator" in res.top.event.features:
            pick = (user, t, res)
            break
    print()
    print("ONE UNSUBSCRIBE, EXPLAINED")
    if pick is not None:
        print(format_result(pick[2]))

    print()
    print("ACROSS ALL USERS: links of 'unsubscribe' (top 8)")
    rep = db.link_report("unsubscribe", min_support=20)
    print(f"  {'feature':<22} {'mean':>6} {'90% CI':>15} {'lift':>6} {'exposures':>10}")
    for r in list(rep)[:8]:
        print(f"  {r.feature:<22} {r.strength:>6.3f} [{r.lo:.3f}, {r.hi:.3f}] {r.lift:>6.2f} "
              f"{exposure[r.feature]:>10,}")

    print()
    print("GUARDRAIL TABLE: feature_risk of candidate exposures (population links)")
    candidates = [
        ("push in a burst", "notification", {"surface:push", "notif:burst", "category:news"}),
        ("clickbait feed item", "impression", {"surface:feed", "trait:clickbait", "category:news"}),
        ("new-creator feed item", "impression", {"surface:feed", "trait:new_creator", "category:news"}),
        ("off-taste autoplay", "impression", {"surface:autoplay", "trait:off_taste", "category:news"}),
        ("single push", "notification", {"surface:push", "category:news"}),
        ("autoplay item", "impression", {"surface:autoplay", "category:news"}),
        ("email digest", "notification", {"surface:email", "category:news"}),
    ]
    print(f"  {'candidate':<24} {'unsubscribe':>11} {'hide':>7} {'regret':>7}")
    rows = []
    for name, typ, feats in candidates:
        u = db.feature_risk("unsubscribe", feats, type=typ)
        h = db.feature_risk("return_or_hide", feats, type=typ)   # notifications: not a hide cause
        rows.append((u, name, h, 1 - (1 - u) * (1 - h)))
    for u, name, h, regret in sorted(rows, reverse=True):
        print(f"  {name:<24} {u:>11.3f} {h:>7.3f} {regret:>7.3f}")
    print("  sorted by unsubscribe risk; regret = 1 - (1 - unsubscribe) x (1 - hide);")
    print("  hide is 0 for notifications (only impressions can be hidden)")

    # Single-feature unsubscribe risk over every surface and trait.
    singles = sorted({f for e in events for f in e[3]
                      if f.startswith(("surface:", "trait:", "notif:"))})
    risk = sorted(((db.feature_risk("unsubscribe", {f}), f) for f in singles), reverse=True)
    order = [f for _, f in risk]
    top = exposure.most_common(1)[0][0]
    nc = order.index("trait:new_creator") + 1
    nc_lift = rep.row("trait:new_creator").lift
    print()
    print("CHECK")
    print(f"  unsubscribe risk, {len(order)} surface/trait/notif features: #1 {order[0]} {risk[0][0]:.3f}, "
          f"#2 {order[1]} {risk[1][0]:.3f}, #3 {order[2]} {risk[2][0]:.3f}")
    print(f"  most common exposure {top} ranks #{order.index(top) + 1} of those {len(order)}; "
          f"trait:new_creator (no true effect) ranks #{nc}, lift {nc_lift:.2f}")
    ok = set(order[:2]) == set(SEEDED)
    print(f"  seeded causes {' + '.join(SEEDED)} in top 2: {'yes' if ok else 'NO'}")
    if not ok:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
  • The learned unsubscribe links put notif:burst first (0.011, lift 4.36) and trait:clickbait second (0.006, lift 2.46). The CHECK confirms both seeded causes rank in the top 2.
  • surface:autoplay is the most common exposure, yet it ranks #5 of the 10 surface, trait and notification features by single-feature risk. Volume alone does not make a feature look harmful, because settle penalizes the many autoplay windows that pass quietly.
  • surface:push ranks #3 (0.004). Pushes are the carrier of bursts, and heavy users get more of them. This is association under the model; a holdout would tell how much plain pushes matter.
  • trait:new_creator rides along with bursts in the simulation, but ends with lift 0.63, below baseline. In the explained case for user-0727 it still shares the top suspect's noisy-OR with notif:burst and trait:clickbait. A reviewer should read the burst, not the creator, as the likely cause.
  • The guardrail table ranks "push in a burst" first at 0.016 for unsubscribe and "off-taste autoplay" highest on combined regret at 0.069, driven by hides.

Run it:

pip install heapdb
python 0008-negative-feedback-and-regret.py   # copy the script from "Show the full script" above

Evaluation

  • Attribution: on regret events with an explicit reason, held out from training, measure how often the named exposure is the top-1 or in the top-3 of top_causes. The baseline is "most recent exposure" and "most recent push".
  • Guardrail: rank features by feature_risk and compare with lift from a randomized holdout, for example users with a push cap versus no cap. The baseline is raw regret rate per feature, which ignores timing and confounding by volume.
  • Calibration: calibration_report on confirmed and rejected exposures, to see whether scores can be compared across users.
  • Ground truth comes from explicit reasons and from holdout experiments, not from the observational links.

Limits and risks

  • These are associations under the model, not incremental effects. Before a score changes ranking, test it with a holdout or a capped arm.
  • Confounding: heavy users get more of everything. surface:push partly picks up the burst effect. Per-user propensity adjustment is not in v0.4.
  • Feedback loops: if regret scores demote a surface, the system sees less of it and learns less about it. Keep a small exploration share and track how link evidence changes.
  • Fairness to new creators: a trait that travels with a harmful pattern can pick up blame. Report traits like trait:new_creator separately, check their lift against a holdout, and do not penalize them on observational links alone.
  • Privacy: exposure logs and regret reasons are behavioral data. Keep only coarse features, honor deletion with forget_entity, and respect consent for personalization.
  • A human decides policy changes such as notification caps. HeapDB ranks suspects and gives reasons.

Prior art

Recommender systems commonly collect explicit negative feedback, such as hide, "not interested" and "see less", and use it as a ranking signal. Many teams also add guardrails beyond clicks: user surveys, long-term value estimates, notification caps and holdout groups that measure long-run retention. Delayed outcomes, where regret shows up hours or days after the exposure, are a known training problem; see the sibling RFC on delayed conversion credit.

HeapDB complements these practices. It does not replace the ranking model or the experiments. It adds a per-incident, explained ranking of likely culprits and a learned per-feature risk that can feed a guardrail.

Status and open questions

In design. The example on this page runs today. Your answers to these open questions shape what we build.

  • How should a regret score enter ranking: as a hard cap, a multiplicative penalty or a model feature?
  • Should a burst be a feature on the push, or a separate aggregate event with a weight equal to the push count?
  • How can per-user propensity, such as heavy versus light use, be adjusted for inside observe and settle?
  • How much exploration is needed to keep links on demoted surfaces from going stale?