Delayed conversion credit for recommendations
Which earlier recommendation exposure most likely earned this purchase, when purchases arrive hours to weeks later?
Summary
We give a recommender team per-purchase credit. For each purchase, HeapDB returns a ranked, explained list of the earlier exposures that most likely earned it. It runs as a sidecar next to your unchanged recommender, whether that's a shop, a streaming service or a marketplace.
The team asks this when purchases or subscriptions arrive hours to weeks after the exposure. Last-click rules then credit whatever came right before, often a search the user ran to find an item they had already decided to buy. With HeapDB, the team gets delay-aware credit per purchase, written back as training labels and as an attribution report by surface and action.
Model
| HeapDB | Recommendations |
|---|---|
| Entity | A user (or account). |
| Events | Exposures, type exposure: an impression, click, save, add-to-cart or email shown to the user. |
| Features | surface:home, surface:search, surface:email, surface:detail, surface:push; action:impression, action:click, action:save, action:cart; category:<x>; position bucket pos:top, pos:mid, pos:low. |
| Weight (dose) | Derived from the action with register_event_type("exposure", weight=...): impression 0.3, click 1.0, save 1.0, cart 1.2. |
| Effects and kernels | purchase with LogNormalKernel(median=3 * D, sigma=0.6, tail_sigmas=3.0): peaks after about two days, support about 18 days. A second, short-lag effect (for example a same-session add-to-cart) could use its own kernel. |
| Leak | leak=0.05: purchases that no exposure in the window explains, such as habitual or offline-driven buys. |
| Rules | None in the example. A team could down-weight exposures it knows are navigation, but that bakes in an answer the model should learn. |
Links start from default_prior=(1, 9): most exposures earn nothing.
Learning
- Observe (no label). Every purchase calls
observe(user, "purchase", at). Each exposure in the kernel window gets soft credit in proportion to its current score, and the leak takes the rest. - Settle.
settle(until)adds negative evidence to exposures whose window has fully passed with no purchase. "Passed" meansuntilis beyond the exposure time plus the kernel support (about 18 days here). Exposures that already received soft credit are not rejected. - Feedback (hard link). When a purchase carries a tracked link, such as an email coupon code,
feedback(user, "purchase", at, confirmed_event_id=...)confirms that exposure. This is the only labeled signal, and it is sparse. - Priors. A sceptical default prior per feature. Each user's links borrow from a global layer, so a new user inherits the population view.
Sidecar architecture
The system of record stays the recommender's exposure log and the order or subscription system. Exposures and purchases reach HeapDB from an outbox table, change data capture or the existing event stream. A periodic job calls settle with the stream's watermark.
HeapDB writes back two things: per-purchase top_causes (as soft training labels or a credit table) and a link_report by surface and action. The recommender does not change. HeapDB is rebuildable by replaying the source logs, so it can be dropped and rebuilt at any time.
Worked example
A synthetic shop sends a weekly email digest; opened emails truly drive purchases about two to five days later. Search causes nothing, but before most purchases the user searches for the item minutes to hours earlier, so last-click credits search.
Output of 0003-delayed-conversion-credit.py
SCENARIO
1,800 users, 57,711 exposures over 60 days, 1,092 purchases
(658 caused by an exposure, the rest background); 94 confirmed by coupon code
settle: 47,891 exposures passed their window with no purchase
ONE CASE, EXPLAINED
user-1213 bought at day 2.6; true cause: #39144 (email, 2.6 days earlier); last click was a search
Top causes of 'purchase' for user-1213 at t=223338:
1. #39144 exposure@733.772 [action:click,category:toys,pos:top,surface:email] w=1 score=0.1919
lag 61.83h -> kernel 0.942 x weight 1 x link 0.204 [noisy-OR of action:click=0.06 (a=1.68, b=26.19), category:toys=0.06 (a=1.40, b=21.99), surface:email=0.05 (a=1.45, b=25.94), pos:top=0.05 (a=1.49, b=29.07)]
2. #39145 exposure@94759.3 [action:click,category:shoes,pos:mid,surface:detail] w=1 score=0.1622
lag 35.72h -> kernel 0.851 x weight 1 x link 0.191 [noisy-OR of action:click=0.06 (a=1.68, b=26.19), surface:detail=0.06 (a=1.19, b=20.38), category:shoes=0.05 (a=1.14, b=23.17), pos:mid=0.04 (a=1.11, b=24.20)]
3. #39146 exposure@138409 [action:click,category:garden,pos:top,surface:detail] w=1 score=0.0887
lag 23.59h -> kernel 0.452 x weight 1 x link 0.196 [noisy-OR of action:click=0.06 (a=1.68, b=26.19), surface:detail=0.06 (a=1.19, b=20.38), pos:top=0.05 (a=1.49, b=29.07), category:garden=0.05 (a=1.08, b=21.09)]
[segments: 3 scanned / 1 pruned / 4 in window; events scored: 4]
ACROSS ALL CASES
credited surface, 658 seeded purchases: true last-click HeapDB top-1
email 493 65 311
push 46 9 40
detail 107 50 173
home 12 30 51
search 0 503 83
Links of 'purchase' for (global) (baseline 0.008; associations under the model, not causal effects):
feature mean 90% CI lift n alpha beta
action:click 0.013 [0.012, 0.014] 1.68 18748.4 241.72 18516.68
surface:email 0.011 [0.009, 0.012] 1.42 13007.5 141.51 12876.00
surface:detail 0.009 [0.007, 0.011] 1.16 8628.6 77.13 8561.45
action:cart 0.009 [0.004, 0.014] 1.11 812.7 7.02 815.70
action:save 0.008 [0.006, 0.011] 1.09 3173.9 26.62 3157.25
surface:push 0.007 [0.004, 0.009] 0.85 3066.7 20.19 3056.47
surface:search 0.005 [0.004, 0.007] 0.71 8681.6 47.58 8643.99
surface:home 0.004 [0.003, 0.005] 0.56 15597.1 66.54 15540.53
action:impression 0.003 [0.003, 0.004] 0.42 26246.4 85.46 26170.95
CHECK
true causal exposure = last click in 9.6% of seeded purchases; = HeapDB top-1 in 56.4%
surface:email ranks #1 of 5 surfaces; surface:search ranks #4 (last-click credits it most)Show the full script (212 lines)
"""Delayed conversion credit: which exposure most likely earned this purchase?
Simulates a shop whose recommender shows items to 1,800 users on five surfaces
(home, search, email, detail, push) over 60 days. Every exposure is an event with
features surface:<s>, action:<impression|click|save|cart>, category:<c> and
pos:<top|mid|low>. Purchases arrive hours to weeks later. HeapDB runs as a
sidecar: it learns from purchases without labels (observe), from exposure
windows that pass without a purchase (settle), and from the few purchases that
carry a tracked email coupon code (feedback).
Hidden ground truth (per exposure, noisy-OR; lag is LogNormal, median 3 days):
* email digest, opened (surface:email + action:click): 0.10
* email digest, delivered but not opened (action:impression): 0.008
* saved-for-later reminder (surface:push + action:click): 0.04
* item detail page clicks: 0.012; home impressions: 0.001
* search: 0.0. Search is navigation: before most purchases the user searches
for the item they already decided to buy, 5 minutes to 3 hours earlier.
So last-click credit goes to search. That is the confounder this example shows.
Run:
pip install heapdb
python 0003-delayed-conversion-credit.py
"""
from __future__ import annotations
import math
import random
from heapdb import HeapDB, LogNormalKernel, format_result
M, H, D = 60.0, 3600.0, 86400.0
SEED = 3
N_USERS = 1800
DAYS = 60
SURFACES = ["home", "search", "email", "detail", "push"]
CATEGORIES = ["shoes", "kitchen", "books", "toys", "garden", "audio"]
ACTION_WEIGHT = {"impression": 0.3, "click": 1.0, "save": 1.0, "cart": 1.2}
def truth(surface: str, action: str) -> float:
"""Hidden probability that one exposure causes a purchase."""
if surface == "email":
return 0.10 if action == "click" else 0.008
if surface == "push":
return 0.04 if action == "click" else 0.0
if surface == "detail":
return 0.012
if surface == "home" and action == "impression":
return 0.001
return 0.0
BACKGROUND_PER_USER = 0.25 # purchases with no exposure as cause
NAV_SEARCH_P = 0.75 # share of purchases preceded by a navigational search
COUPON_P = 0.20 # share of email-caused purchases with a tracked coupon
def true_lag(rng: random.Random) -> float:
return math.exp(rng.gauss(math.log(3 * D), 0.5))
def exposure(rng: random.Random, t: float) -> tuple[float, str, str, str, str]:
s = rng.choices(SURFACES, weights=[40, 22, 8, 22, 8])[0]
if s == "email":
a = "click" if rng.random() < 0.25 else "impression"
elif s == "push":
a = "click" if rng.random() < 0.3 else "impression"
elif s == "search":
a = rng.choices(["click", "impression"], weights=[6, 4])[0]
elif s == "detail":
a = rng.choices(["click", "save", "cart"], weights=[7, 2, 1])[0]
else:
a = rng.choices(["impression", "click", "save"], weights=[8, 2, 1])[0]
pos = rng.choices(["top", "mid", "low"], weights=[3, 4, 3])[0]
return t, s, a, rng.choice(CATEGORIES), pos
def simulate(rng: random.Random):
"""Per user: exposures (t, surface, action, category, pos) and purchases (t, cause_index)."""
users = {}
for u in range(N_USERS):
exps = []
for _ in range(rng.randint(10, 40)):
exps.append(exposure(rng, rng.uniform(0, DAYS * D)))
for day in range(0, DAYS, 7): # weekly email digest
if rng.random() < 0.7:
t = (day + rng.uniform(0, 1)) * D
exps.append((t, "email", "click" if rng.random() < 0.3 else "impression",
rng.choice(CATEGORIES), "top"))
purchases = []
for i, (t, s, a, _c, _p) in enumerate(exps):
if rng.random() < truth(s, a):
purchases.append((t + true_lag(rng), i))
if rng.random() < BACKGROUND_PER_USER:
purchases.append((rng.uniform(5 * D, DAYS * D), None))
purchases = [p for p in purchases if p[0] < DAYS * D]
for tp, _ in purchases: # navigation: search for the thing
if rng.random() < NAV_SEARCH_P:
exps.append((tp - rng.uniform(5 * M, 3 * H), "search", "click",
rng.choice(CATEGORIES), "top"))
users[f"user-{u:04d}"] = (exps, sorted(purchases))
return users
def surf(ev) -> str:
"""Surface of an event, or 'none'."""
if ev is None:
return "none"
return next(f for f in ev.features if f.startswith("surface:"))[len("surface:"):]
def main() -> None:
rng = random.Random(SEED)
users = simulate(rng)
db = HeapDB(bucket_seconds=12 * H)
db.register_event_type("exposure", weight=lambda a: ACTION_WEIGHT[a["action"]])
db.register_effect("purchase", LogNormalKernel(median=3 * D, sigma=0.6, tail_sigmas=3.0),
default_prior=(1, 9), leak=0.05)
ids: dict[str, list] = {} # user -> event per exposure index (insertion order)
n_ev = 0
for user, (exps, _) in users.items():
order = sorted(range(len(exps)), key=lambda i: exps[i][0])
evs = [None] * len(exps)
for i in order:
t, s, a, c, p = exps[i]
evs[i] = db.insert(user, t, "exposure",
{f"surface:{s}", f"action:{a}", f"category:{c}", f"pos:{p}"},
attrs={"action": a})
n_ev += 1
ids[user] = evs
all_purchases = sorted((tp, user, cause) for user, (_, ps) in users.items() for tp, cause in ps)
for tp, user, _ in all_purchases:
db.observe(user, "purchase", tp)
settled = db.settle(DAYS * D + 30 * D)
# Hard links: a tracked email coupon code ties some purchases to one email.
n_fb = 0
for tp, user, cause in all_purchases:
if cause is not None and users[user][0][cause][1] == "email" and rng.random() < COUPON_P:
db.feedback(user, "purchase", tp, confirmed_event_id=ids[user][cause].id)
n_fb += 1
seeded = [(tp, u, c) for tp, u, c in all_purchases if c is not None]
print("SCENARIO")
print(f" {N_USERS:,} users, {n_ev:,} exposures over {DAYS} days, {len(all_purchases):,} purchases")
print(f" ({len(seeded):,} caused by an exposure, the rest background); "
f"{n_fb} confirmed by coupon code")
print(f" settle: {settled.events_rejected:,} exposures passed their window with no purchase")
# Last-click vs HeapDB top-1 on purchases whose true cause is known.
last_ok = top_ok = 0
last_by: dict[str, int] = {}
top_by: dict[str, int] = {}
true_by: dict[str, int] = {}
pick = None
for tp, user, cause in seeded:
evs = ids[user]
true_ev = evs[cause]
clicks = [e for e in evs if e.time < tp and e.attrs["action"] != "impression"]
last = max(clicks, key=lambda e: e.time) if clicks else None
res = db.top_causes(user, "purchase", tp, k=3)
n_cand = sum(c.score >= 0.01 for c in res)
top = res.top.event if res.top else None
last_by[surf(last)] = last_by.get(surf(last), 0) + 1
top_by[surf(top)] = top_by.get(surf(top), 0) + 1
true_by[surf(true_ev)] = true_by.get(surf(true_ev), 0) + 1
last_ok += last is not None and last.id == true_ev.id
top_ok += top is not None and top.id == true_ev.id
if (pick is None and n_cand == 3 and top is not None and top.id == true_ev.id
and surf(last) == "search" and surf(true_ev) == "email"):
pick = (user, tp, true_ev)
print()
print("ONE CASE, EXPLAINED")
user, tp, true_ev = pick
print(f" {user} bought at day {tp / D:.1f}; true cause: #{true_ev.id} "
f"(email, {(tp - true_ev.time) / D:.1f} days earlier); last click was a search")
print(format_result(db.top_causes(user, "purchase", tp, k=3, explain=True)))
print()
print("ACROSS ALL CASES")
print(f" credited surface, {len(seeded):,} seeded purchases: true last-click HeapDB top-1")
for s in ["email", "push", "detail", "home", "search"]:
print(f" {s:<10} {true_by.get(s, 0):>34} {last_by.get(s, 0):>12} {top_by.get(s, 0):>14}")
rep = db.link_report("purchase", min_support=20,
features=[f"surface:{s}" for s in SURFACES]
+ [f"action:{a}" for a in ACTION_WEIGHT])
print(rep)
print()
print("CHECK")
surf_rank = [r.feature for r in rep if r.feature.startswith("surface:")]
last_share, top_share = last_ok / len(seeded), top_ok / len(seeded)
print(f" true causal exposure = last click in {last_share:.1%} of seeded purchases; "
f"= HeapDB top-1 in {top_share:.1%}")
print(f" surface:email ranks #{surf_rank.index('surface:email') + 1} of "
f"{len(surf_rank)} surfaces; surface:search ranks "
f"#{surf_rank.index('surface:search') + 1} (last-click credits it most)")
if (surf_rank[0] != "surface:email" or top_share <= last_share
or max(last_by, key=last_by.get) != "search"):
raise SystemExit(1)
if __name__ == "__main__":
main()- In the explained case, the email click 61.83h earlier ranks first with score 0.1919. The search click right before the purchase is not in the top 3: at that short a lag the kernel is near 0.
- Across 658 seeded purchases, last-click credits search 503 times, though search caused none. HeapDB top-1 credits search 83 times and email 311 times, against a true 493.
- The true causal exposure is the last click in 9.6% of seeded purchases and HeapDB's top-1 in 56.4%.
- In the link report,
surface:emailranks first of 5 surfaces with lift 1.42, andsurface:searchranks fourth with lift 0.71. Search still keeps some credit, and HeapDB over-creditssurface:detail(173 top-1 against a true 107).
Run it:
pip install heapdb
python 0003-delayed-conversion-credit.py # copy the script from "Show the full script" aboveEvaluation
- Metric. Top-1 and top-3 accuracy of per-purchase credit, plus rank agreement of the surface-level
link_reportwith measured lift. - Baseline. A last-touch rule (the last non-impression exposure before the purchase), and a fixed time-decay rule with no learning. HeapDB must beat both on top-1.
- Ground truth. On real data, true causes are unknown. Use purchases with hard links (coupon codes, tracked email links) held out from feedback. For surface-level claims, use holdout experiments: suppress a surface for a random user group and measure the difference in purchases.
Limits and risks
- Credit is not incrementality. HeapDB reports associations under the model, not causal effects. Holdout experiments stay the source of truth for whether a surface earns purchases.
- Search before buying is a classic confounder. The example shows HeapDB reduces this bias, not removes it. Any exposure that follows intent rather than creating it can be over-credited.
- Feedback loops. If credit becomes training labels, the recommender shows more of what got credit. That creates more exposures of that kind, and more credit. Keep a random exploration share and audit label drift.
- Model fit. One kernel for all surfaces is a simplification. Surfaces with different delays, or users who buy on a schedule, will be credited less accurately.
- Privacy. Exposure logs are behavioral data. Keep retention short, honor deletion with
forget_entity(user, scrub_global=True), and report surfaces, not individuals. - Decisions stay with people. Budget, ranking and email-frequency changes should be made by a person, after an experiment.
Prior art
Delayed conversions are a known problem in display advertising. Chapelle (KDD 2014) notes that conversions can happen long after the impression, up to a month later, and models the conversion delay jointly with the conversion probability. Follow-up work handles delayed feedback when training models.
Practice in attribution also includes last-touch and rule-based multi-touch models, and holdout or geo experiments for incrementality. HeapDB is a complement: it adds per-purchase, explained, delay-aware credit that learns from unlabeled purchases. It does not replace conversion-rate models or experiments.
Status and open questions
In design. The example on this page runs today. Your answers to these open questions shape what we build.
- Should each surface have its own lag kernel, and can HeapDB learn kernel parameters instead of fixing them?
- How should credit be turned into training labels without a feedback loop: soft labels, thresholds, or exploration-only data?
- Should the short-lag effect (same-session add-to-cart) be a separate effect or a second mode of one kernel?
- How should exposures shared across devices or household accounts map to one entity?