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?
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
| HeapDB | Recommendations |
|---|---|
| Entity | One user |
| Events | Served items (impression) and sent notifications (notification) |
| Features | surface: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 kernels | unsubscribe: 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) |
| Leak | 0.05 for both effects: users also unsubscribe for reasons outside the stream |
| Rules | surface: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:
settleadds negative evidence for exposures whose kernel window passed with no regret. Forunsubscribethe window is the log-normal kernel's support, about nine days. Forreturn_or_hideit 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: yesShow 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:burstfirst (0.011, lift 4.36) andtrait:clickbaitsecond (0.006, lift 2.46). The CHECK confirms both seeded causes rank in the top 2. surface:autoplayis 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:pushranks #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_creatorrides 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 withnotif:burstandtrait: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" aboveEvaluation
- 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_riskand 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_reporton 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:pushpartly 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_creatorseparately, 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
observeandsettle? - How much exploration is needed to keep links on demoted surfaces from going stale?