Six years building marketing measurement infrastructure from the ground up — attribution, incrementality, LTV/CAC. Most recently the first analytics hire embedded in marketing at a Series D edtech marketplace, owning measurement across ~$10M in annual media.
Every ad platform reports its own contribution, and those numbers never reconcile — with each other or with ours. Add up what Meta, Google, and TikTok separately claim and the total comfortably exceeds what actually happened. Somebody has to arbitrate, and it has to be us.
Deciding where the next dollar goes means answering five questions — and they only work in order. Each one is worthless if the one above it is wrong. That dependency is why my work spans all five rather than living in any single one of them.
Click any project for the full architecture, diagrams, and design decisions.
Replaced ~15 overlapping legacy models with one unified touchpoint stream, and shipped payment-level attribution, so we could finally see which channels brought paying customers rather than free signups. Spec'd it, built it in four phased merge requests, retired the old layer.
Led an audit of the conversion events flowing between our product, Segment, and the ad platforms. The clearest case of attribution work being blocked upstream — so I went upstream.
Geo-lift tests with 50% holdouts across paid social, plus a measurement vendor migration landed with no coverage gap. And an email content experiment against a Thompson Sampling bandit.
Took a daily CAC tracker that was ~60% hand-copied and put every source on a managed pipeline. Built the spend model layer underneath it from scratch.
Unit economics: CAC, LTV:CAC and payback by channel. Partnered with the ML team on LTV modeling, and how I'd build predictive LTV for a freemium subscription business.
Marketing analytics lead building measurement infrastructure from the ground up — attribution modeling, incrementality testing, LTV/CAC. I own measurement end to end, setting the KPI standards and the infrastructure behind budget decisions.
Georgia Institute of Technology — M.S. Analytics, expected 2028
University of Rochester — M.S. Marketing Analytics, 2019
Nanjing University — B.B.A. Business Administration, 2017
MTA · MMM · incrementality · A/B testing · causal inference · LTV modeling · LTV/CAC · segmentation (RFM, clustering)
I wrote the tech spec, designed the architecture, shipped it across four phased merge requests, validated it against the old outputs, and retired the legacy layer.
To understand why the attribution layer stopped working, you need the commercial history underneath it. The marketplace changed what it sold twice in a few years, and each change altered what a "conversion" even means.
Attribution built for stage 1 handles stage 3 badly. The pipeline had grown a separate lineage of models for each revenue type — one chain for enrollments, another for memberships, a third resolving users at signup — each maintained independently as the business changed shape around them.
"Which channel won this customer" had two or three different answers depending on which model you happened to query, and no defined way to settle between them. There was no concept of a customer's first purchase spanning both revenue types — so the most basic acquisition question had no single owner in the data.
And the honest caveat, because anyone who has done this work knows it: none of this makes the platform numbers agree with ours. That gap is structural — each platform measures inside its own walled garden, on its own attribution window, counting conversions it touched. Reconciliation isn't a problem you solve once; it's one you manage continuously. What a rebuild like this buys you is a single internal number that's consistent, explainable, and defensible enough to argue from — which is why the incrementality work sits alongside it as the independent check.
One chain per conversion type, each maintained separately. The same question — who gets credit — answered by different SQL in different places.
Channel mapping applied at different points in different chains, so one touchpoint could bucket differently depending on which model you queried.
Two inputs are mutable — a self-reported survey and a hand-maintained coupon sheet. When they changed historically, nothing rebuilt the affected users.
The core decision: collapse every attribution-bearing event into one table at touchpoint grain, then apply dedup, enrichment, and ranking once. Downstream models select and filter; they never re-derive attribution.
Event IDs are namespaced by type, so synthetic touchpoints — ones with no clickstream event behind them — stay visibly distinct from real ones:
| visit_ | real · page view |
| user_signup_ | real · signup event |
| enrollment_ | real · class purchase |
| membership_ | real · subscription purchase |
| hdyhau_ | synthetic · created from a survey response |
| coupon_ | synthetic · created from a coupon redemption |
This runs on Redshift, so physical layout mattered as much as logic.
Every touchpoint has to become a channel, and "channel" means different things to different people. Finance wants paid versus organic. A channel manager wants to know which Meta campaign type. The CMO wants paid social as one line. Serving all three from separate logic is how a taxonomy starts drifting.
So the hierarchy has four levels — and the critical design decision is that only the deepest one is derived from raw signals. The three above it are composed from that leaf, never re-classified independently.
| Raw signal | L4 — leaf | L3 | L2 | L1 |
|---|---|---|---|---|
| utm source=facebook medium=paid_social |
facebook_other_ads | paid_facebook | paid_social | paid |
| referrer=facebook.com no utm |
organic_facebook | organic_facebook | organic_social | organic |
| referrer=chatgpt.com | chatgpt | chatgpt | organic_search | organic |
| coupon code → partner mapping |
community | referral | WOM | organic |
| utm medium=email | email_other | organic |
The interesting part wasn't the code. It was that several of these categories have no technically correct answer, and the business had never written one down:
I drove this to written closure in a shared doc with marketing, then encoded the agreed mapping in the macro. The point of that sequencing: get the decision made by the people who own the definition, then make the code the single record of it — so the argument can't quietly restart in six months.
Every rule on this page is easier to see applied to a single person. Here's a parent who finds the marketplace through paid search, takes a month to buy, answers the post-purchase survey, and uses a coupon — traced through each stage of the pipeline.
| Day | Event | After dedup | After enrichment | In 28-day window | Credit |
|---|---|---|---|---|---|
| 2* | hdyhau · "reddit" | — | synthetic row created | ✓ by construction | 0.25 |
| 1 | visit · google paid | kept — run of 2 collapsed | ✓ | ✕ outside window | — |
| 1 | visit · google paid | dropped — consecutive | — | — | — |
| 3 | visit · facebook paid | ✓ | ✓ | ✓ | 0.25 |
| 5 | visit · email | kept — run of 2 collapsed | dropped — pre-signup | — | — |
| 5 | visit · email | dropped — consecutive | — | — | — |
| 8 | visit · direct | ✓ | ✓ | ✓ | excluded |
| 12 | SIGNUP · organic google | ✓ | ✓ | ✓ | 0.25 |
| 30* | coupon · "community" | — | synthetic row created | ✓ by construction | 0.25 |
| 30 | ENROLLMENT — first purchase | ✓ | ✓ | conversion anchor | — |
If someone arrives eight times through the same Facebook campaign before converting, linear attribution hands that campaign 8/9ths of the credit. High-frequency channels — email, retargeting — systematically over-earn against channels people touch once.
found this one the hard way — in review, not in productionThe obvious fix is to collapse repeats. The trap is collapsing too much: if someone sees a Facebook ad, then gets an email, then comes back through Facebook a week later, that second Facebook touch is a genuine, separate re-engagement. It should survive. Only consecutive repeats are noise.
So the rule I needed was "collapse each unbroken run of the same channel into one touchpoint" — which means the logic has to know where runs start and end.
One deliberate exclusion: I dropped the channel-level cooldown windows I'd originally sketched — a 7-day window for email, 1 day for everything else. Fixed cooldowns collapse touchpoints that are genuinely separate whenever a campaign's natural cadence happens to sit inside the window, and the run-based rule already handles the case they were meant to solve.
| Condition | Action | |
|---|---|---|
| B1 | Group has conversions; row is the first conversion | Keep, original attribution |
| B2 | Group has conversions; row is a later conversion | Keep, neutralize to direct |
| B3 | Group has conversions; row is first event and a visit | Keep, neutralize to direct |
| B4 | Visit-only group; row is first | Keep, original attribution |
| B5 | No user ID | Pass through unchanged |
| — | anything else | Dropped |
That convergence problem is ultimately why this model is materialized as a table rather than incrementally. I built and kept the incremental version — it's still in the file, commented, with a note explaining the switch — but correctness across the full touchpoint history was worth more than the runtime saving.
Two attribution inputs are mutable. A self-reported survey gets answered late or reprocessed. A coupon-code-to-channel sheet is maintained by hand, and marketing recategorizes codes retroactively. A fixed-lookback incremental model misses both silently — remap a code in September for codes used in March, and March is now wrong with nothing to trigger a rebuild.
visits · signups · enrollments · memberships
→ ordinary incremental append, last 24h
survey responses · coupon→channel mapping
→ detect rows changed since last run
→ build impacted-user list
→ reprocess those users' entire history
The trade-off I'd defend: this deliberately spends more compute than a pure append. The alternatives were worse. A nightly full refresh is correct but doesn't scale with event volume; a pure incremental is cheap but silently wrong the moment a coupon mapping changes. The impacted-list approach targets exactly the rows that can be stale and leaves everything else alone.
Survey responses and coupon redemptions are real attribution signals, but they aren't clickstream events — they have no natural position in a touchpoint sequence. Two questions had to be answered.
A survey answer describes how someone first found us, so it has to rank first. A coupon is redeemed at checkout, so it has to rank last. Rather than special-casing the ordering logic in every downstream model, I emit synthetic rows at engineered timestamps — and correct sort order then falls out of ordinary chronological ordering.
If someone answers "I found you on Facebook" and a Facebook touchpoint already exists in their 28-day window, adding a synthetic row double-counts the same channel. So enrichment is conditional — expand only if the channel isn't already represented. This is the highest-risk logic in the pipeline, and it's what the unit tests target.
direct is the absence of information. Treating it as a channel lets it absorb credit that belongs somewhere else. The rules I landed on, after stakeholder alignment:
UTM parameters get stripped by redirects, mangled by link shorteners, and dropped by in-app browsers. Platform click identifiers — gclid, gbraid, wbraid, fbclid, msclkid, ttclid — survive where UTMs don't.
I added them as an override tier with a deliberately narrow precedence rule. Click IDs override UTM attribution only when a click ID is present but UTM claims organic, direct, or a non-target platform — or when no click ID is present but UTM claims paid.
Why narrow: "click ID always wins" is simpler, but it would override legitimate organic attribution any time a stale click ID lingers in a URL someone shared.
The projected correction: my exploratory analysis put it at a few percent for paid Google and a substantially larger double-digit shift for paid Meta last-touch signup attribution. Meta is far worse because iOS link handling strips UTM parameters much more aggressively — meaning a meaningful share of paid social signups had been credited to the wrong channel entirely.
Attribution fails quietly. There's no exception, no null pointer — just a number that's slightly wrong in a direction nobody notices until someone questions a channel's budget months later. So the testing had to be layered, with each layer aimed at a different kind of wrong.
| Layer | Catches | Runs | On failure |
|---|---|---|---|
| Schema | Structural breakage — duplicated keys, nulls where there shouldn't be, broken joins | CI on every MR, then daily | Error — blocks the merge |
| Domain | A value nobody has a rule for — a new survey option, an unmapped coupon channel | Daily | Error — unmapped values must not silently bucket to "other" |
| Invariant | Internal contradiction — the weights for a conversion no longer sum to 1 | CI + daily | Error — the logic is wrong somewhere upstream |
| Unit | Edge-case regression in the enrichment rules, against fixed inputs | CI on every MR | Error — a known case changed behaviour |
| Monitoring | Upstream drift — coverage falling, freshness lagging, volumes moving | Daily, continuously | Alert — investigate, don't block |
Uniqueness and not-null on the primary key of every model in the DAG. Unglamorous and the first thing to catch a bad join — a duplicate row at the touchpoint grain silently double-counts credit downstream.
Where one table holds two source types with different keys, the tests are conditional:
- name: user_id
data_tests:
- unique: { where: "source = 'hdyhau'" }
- not_null: { where: "source = 'hdyhau'" }
- name: entity_id
data_tests:
- unique: { where: "source = 'coupon'" }
And where a key can legitimately be null, the yml records why. Absence of a test should be a decision someone wrote down, not an oversight nobody noticed.
Accepted-values on the survey response set (19 options) and the coupon channel set (12). These exist because of a specific failure mode: someone adds a new option in a product form or a new partner channel in a spreadsheet, it arrives with no mapping rule, and it quietly lands in other. Attribution for that channel simply never appears, and nothing anywhere signals a problem.
the bug that looks like "that channel just doesn't drive much"I wrote a custom generic test asserting that for any conversion, the attribution weights across its eligible touchpoints sum to 1. It's applied to both scope models, and it's the highest leverage test in the project.
It works because it doesn't check any specific behaviour — it checks a property that must hold if every behaviour is correct. Dedup dropping a row it shouldn't, enrichment expanding a survey twice, ranking mis-scoping a window: all of them break the sum. One assertion standing in for a whole class of bugs.
Three scenario-named cases on the survey overlap logic, which is the riskiest rule in the pipeline — fixed input rows in, expected output asserted:
Naming them as scenarios rather than test_1 matters: when one fails, the failure message already tells you which business rule broke.
Tests assert what should be true of the code. Monitoring watches things that change without anyone touching the code at all.
What share of payments in the product database have an associated conversion event. Drifting coverage means an upstream instrumentation change, not a modelling bug — the thread that connects directly to the tracking audit.
Did each source land, and is its volume within a normal range. A source that silently stops arriving produces a pipeline that runs green and quietly under-reports.
Before/after validation. At every milestone I compared pre- and post-change distributions of attribution channel for signups, new buyers and enrollments, plus paid CAC shift — then wrote a note for stakeholders explaining what moved and why.
No test suite can tell you whether a 6% swing in paid Google's share is a bug or the correction you were aiming for. That judgment is the work; the tests just make sure nothing else moved while you were making it.
Replacing the attribution backend is only half a project. Everyone downstream — marketing, finance, leadership — reads this through dashboards, and those dashboards had to keep answering the same questions the whole way through. A migration that leaves people unable to pull their own numbers for two weeks has failed, regardless of how clean the models are.
That last one mattered more than it sounds. Two independent copies of a channel taxonomy will always drift, and when they do, the argument isn't about the data — it's about whose dashboard is right. Collapsing them to one definition removed a recurring class of meeting.
| Feb 2–9 | Tech spec. Architecture, model-by-model specs, pseudocode, incremental strategy, test plan, rollout plan, risk register. Diagrams for current vs. proposed state. |
| Mar 3 | Milestone 1 — coalesce clickstream and product attribution logic |
| Mar 27 | Visits attribution moved inline; duplicate-row bug fixed |
| Mar 31 | Milestone 2 — core models: unified, dedup, enriched |
| Apr 9 | Milestone 3 — downstream user and entity attribution models |
| Apr 13 | Cutover — core facts rewired and 15 legacy models deprecated in a single merge request |
| May 4–5 | Post-launch — transactional email excluded from entity attribution; attribution ID added to core facts |
I chose milestone-based validation over strictly isolated merge requests. Each milestone was validated by comparing signup attribution before and after. In a DAG this interconnected, small isolated MRs would have left it in inconsistent intermediate states for longer, not shorter.
Roughly 3,000 lines of attribution SQL, built inside a ~900-model dbt monorepo. GitLab merge-request flow with CI/CD, developing against production state with deferred runs so every model change could be validated on real data before it merged.
Write-ups are easy to make sound good. These are the source documents — my design diagrams at full resolution, the tech spec structure, and the code. Locked because they're real internal work product; the password is in my email signature and I'll hand it out in any conversation.
A rebuild like this pays off in three places: questions the business can suddenly answer, work that stops being expensive, and options that open up afterwards.
The thing I'd point to above any single metric: good attribution infrastructure changes what a marketing meeting is about. When four models disagree, the meeting is about whose number is right. When there's one answer everyone trusts, the meeting is about what to do with it — which is the only version of that meeting worth having.
Attribution models are opinions you can compute daily. Incrementality is evidence you can afford occasionally. Two workstreams: a paid-media geo-lift program, and an email content experiment against a bandit.
Everything in projects 01 and 02 produces a correlational answer. Attribution observes that a customer touched a channel and then converted, and distributes credit according to rules I chose. Those rules can be reasonable, documented and consistent — and still not tell you whether the channel caused anything.
That distinction has a price tag. A retargeting campaign shown to people already intending to buy will look magnificent in any attribution model, because it genuinely was the last thing they touched. Whether it changed a single decision is a different question, and attribution structurally cannot answer it.
| Method | Answers | Cannot answer | Cost |
|---|---|---|---|
| Platform-reported | What the platform touched | Anything about channels it didn't serve — and it over-claims by design | Free, arrives instantly |
| Attribution / MTA | A consistent cross-channel view of observed journeys | Causation. It cannot see the counterfactual | Build once, then continuous |
| Incrementality | What would have happened anyway | Fine-grained allocation — it's a blunt instrument at channel level | Real money: you withhold advertising from half your market |
The design: a geo-lift test with a 50% US holdout. Primary metric New Buyers, secondary Signups — deliberately anchored on payment rather than signup, the same principle driving the attribution rebuild.
The output isn't "the campaign drove 4,000 conversions." It's the gap between what happened in test geos and what the control geos say would have happened anyway — with an interval around it, because it's an estimate.
Most failed incrementality tests fail at design, not analysis. Four decisions do the damage:
The incumbent measurement vendor had an existing export pipeline in our warehouse. We moved to a new vendor — and I built the replacement data model a week before the old pipeline was sunset. Successor shipped before predecessor retired, so there was no gap in measurement coverage across the transition.
| Sep 25, 2025 | New vendor data model shipped |
| Oct 2, 2025 | Legacy vendor pipeline sunset — no coverage gap |
Lookalike audiences segmented by family type — homeschooling, traditional-school, and families of neurodivergent children — to test lower-funnel intent against a broad audience. Creative mix of video, influencer Spark Ads, and showcase ads.
Targeting former buyers. Primary metric resurrected buyers, secondary resurrected bookings.
Cold audience, 25+, with 180-day exclusions for recent signups, visitors, and purchasers. Video vs. static ad sets tested against each other.
The second-phase plan was to calibrate attribution channel weights against measured incrementality. That's the right division of labour between the two: attribution allocates credit across touchpoints, incrementality tells you whether the channel caused anything at all. Layering measured lift into the monthly channel report was the roadmap item.
A vendor offered AI-optimized email content — subject lines, copy, recommendations. The question wasn't whether their emails performed better than nothing. It was whether they drove genuinely incremental conversion over our standard emails, whether any lift decayed, and where the cost-benefit break-even sat.
The vendor's algorithm uses Thompson Sampling — it explores early and exploits later. Users were randomly assigned, tests ran on two sends a week with multiple variants each, and the same test could run for several weeks.
That breaks the two obvious ways to read the result. Looking at early periods understates true performance, because the bandit is still exploring. Pooling the entire window mixes exploration with exploitation, so the average describes a system that no longer exists by the end of the test. Evaluating a non-stationary bandit against a fixed control needs the lift curve over time, not a single number — which is also the only way to answer the decay question that was asked.
What I built: the Python evaluation, plus the dbt model producing the metric file sent back to the vendor, joined against user-attribute models for segmentation.
Geo testing gave us a read on true incremental lift that platform-reported conversions couldn't, and spend moved toward channels that survived that check. For the email vendor, the question shifted from "are their emails performing?" to "are they performing better than what we'd have sent anyway, and is that worth the fee?" — which is a different, answerable question.
The vendor migration proved the framework wasn't tied to one supplier: warehouse-side data models, test design and evaluation stayed put while the vendor underneath changed. Each subsequent test is a configuration of an existing process rather than a new project, which is what makes a testing programme possible instead of a series of one-offs.
The strategic value isn't any single result. It's having a mechanism that can settle "is this channel actually working" with evidence — so that question stops being decided by whoever argues most confidently in the meeting.
A daily CAC number that leadership steers spend by was being assembled by hand every morning. I put every source on a pipeline — and built the spend model layer that didn't exist underneath it.
The daily CAC tracker was an Excel sheet. Part of it pulled automatically through Supermetrics; the rest was copied by hand each morning from dashboards, agency spend exports, an affiliate portal, and influencer contracts. Roughly 60% of it was manual.
That arrangement has a specific failure pattern. It works — until the person maintaining it is on holiday, or a platform changes an export format, or someone pastes into the wrong column. It also couldn't refresh over weekends, so Monday began with a two-day gap in the number leadership steers spend by. And the effort scaled linearly with channels: every new platform added another manual pull to somebody's morning.
| Source | Before | After |
|---|---|---|
| Dashboard pulls | hand-copied daily, ~60% of the tracker | consolidated into one backend-verified dashboard |
| Platform spend | agency exports, emailed | Fivetran — Meta, Google, Microsoft, TikTok |
| Affiliate | manual portal download | partner API / SFTP |
| Influencer + coupons | contracts and ad-hoc sheets | Google Sheets → Fivetran |
| Weekend refresh | none | scheduled |
The output was a daily CAC and pacing dashboard in Omni. The metric design matters more than the layout, so here's the structure rather than a screenshot.
Automating the pipeline is the easy half. The half that generates actual disagreement is the numerator: which costs belong in CAC at all. Every inclusion decision moves the number, and each one has someone with a reason to argue the other way.
None of these has a universally right answer. What matters is that the rule is written down, applied consistently, and the same in the dashboard as in the warehouse — otherwise every CAC conversation restarts from first principles.
All of this depends on one unglamorous thing: the join key between what a campaign spent and what it produced. That key is the campaign naming convention, and when it's inconsistent the entire cost side silently degrades — spend lands in "unattributed", channel CAC is wrong, and no error appears anywhere.
It's the least interesting artifact I've worked on and one of the highest-leverage. A convention nobody enforces is just a document; enforcement has to live in the pipeline, where an unparseable campaign name fails loudly rather than quietly bucketing to other.
"What's our CAC" is an ambiguous question, and answering with a single number is how teams end up arguing past each other.
| Measure | What it answers | Where it misleads |
|---|---|---|
| Blended | All acquisition cost ÷ all new customers. The honest company-level number, and the one that ties to finance. | Hides mix. Improves whenever organic grows, even if every paid channel is getting worse. |
| Paid-only | Paid cost ÷ customers attributed to paid. What the marketing team actually controls. | Fully dependent on attribution being right, and ignores paid's influence on conversions that land as organic. |
| Channel | Per-channel cost and customers. The number you set budgets with. | Most sensitive to attribution error, and tempts single-channel optimisation in a system where channels affect each other. |
And a reporting detail that causes real confusion: platforms restate spend retroactively. Yesterday's number is not final — invalid traffic credits and late conversions land days later. A pipeline that appends rather than reprocesses a trailing window will slowly drift out of agreement with the platform's own UI, and the first anyone hears of it is someone asking why the dashboard disagrees with Meta.
The dashboard needed a spend layer that didn't exist yet. Built and iterated over eight months:
This is the least architecturally interesting project here and arguably the most valuable. A correct attribution pipeline terminating in a spreadsheet somebody rebuilds by hand every morning has not changed a single decision.
A manual morning routine became a dashboard that refreshed on its own, including weekends. Marketing and finance stopped reconciling two versions of CAC. And the number arrived early enough in the day to change what happened that day, which is the only reason daily reporting is worth building at all.
The manual process cost more every time a channel was added. The pipeline doesn't — a new platform is a connector and a set of mappings, not a permanent addition to someone's morning. That's the difference between reporting that survives growth and reporting that quietly caps it.
The broader point I'd make about this kind of work: the constraint on most marketing teams isn't analytical sophistication, it's latency. A perfect answer that arrives on Wednesday about Monday's spend is worth less than a good answer available Monday afternoon.
Getting attribution right in the warehouse is only half of it. The conversion events actually reaching Meta and Google are a separate pipeline — and if those are incomplete or carry the wrong payload, the platforms are optimizing against noise no matter how good the warehouse is.
A conversion pixel firing in someone's browser has to survive a long series of obstacles before it reaches an ad platform. Most discussion of attribution assumes the event arrives. Often it doesn't — and the losses aren't random, which is the part that damages measurement.
Server-side events are the structural fix: fired from your own backend when the payment actually records, so they don't depend on the user's browser cooperating. But they introduce two problems browser pixels don't have — identity matching and deduplication.
A set of "golden" server-side conversion events underpinned both marketing optimization and product analytics — and they weren't firing reliably. We audited each one against the core product tables that represent ground truth.
The framework was the same for every event, four questions in order:
| Question | Failure it catches | How it's measured |
|---|---|---|
| Does it fire? | Missing events — silent undercount | Coverage vs. the product table |
| Does it fire once? | Retries, double-binding, pixel + server overlap | Event count vs. row count |
| Is the payload right? | Wrong revenue, missing currency, null IDs | Field-level match vs. source of truth |
| Does it arrive? | Delivered but unmatched at the destination | Platform-reported match rate |
| Product table | Server-side event |
|---|---|
| payments | order_completed · payment_billed (new) |
| users | user_created |
| enrollments | enrolled_in_activity |
| member users | membership_purchase_complete |
>99% of qualifying enrollment and membership payments must have an associated server-side conversion event. Baseline was around 75%.
>99% of the revenue value on the event must match the value recorded in the product database — a conversion carrying the wrong revenue is worse than a missing one, because it actively trains the platform on a false signal.
Backed by data-quality monitoring on the percentage of initialized payments with associated conversion events, validating both event presence and field population.
Events being correct in the warehouse and events being correct at the ad platform are two different claims. The conversions API path has its own failure modes, and this is the layer directly wired to value-based targeting — so an error here doesn't just misreport, it actively misdirects spend.
This is the direct link between instrumentation and the value work in project 05. Value-based targeting is only as good as the value it receives — and a revenue payload mapped from the wrong Segment property, or counted twice, teaches the platform something false about which customers are worth pursuing.
Conversion coverage moved from a baseline around 75% toward a 99% target against product truth, with revenue payloads reconciled to the database. Downstream, both the attribution pipeline and the ad platforms were working from signal that could be checked rather than assumed.
The four-question framework applies to any new event without redesign, and the monitoring means regressions surface on their own. Adding a conversion event or a new platform destination is now a checklist, not an investigation — which matters because this breaks again every time someone reconfigures a destination.
The honest framing: instrumentation isn't a project you finish. It decays continuously as the product ships, platforms change their APIs, and browsers tighten privacy. What you can build is the capacity to notice — and that's the part that compounds.
This is the clearest case in my work of an attribution problem that could not be solved in dbt. I could model payment-level attribution perfectly and the platforms would still be optimizing badly, because the events carrying that signal to the ad platforms were incomplete and their revenue payloads were wrong.
The tempting move is to build a workaround downstream. The correct move was to go upstream with my manager, audit the instrumentation against product truth, and set numeric acceptance criteria — then let the attribution work land on a foundation that could actually carry it.
A cost-per-acquisition number on its own can't tell you whether a channel is worth buying. It only becomes a decision next to what those customers turn out to be worth.
I partnered with our ML team on LTV modeling across two studies — one in 2022, and a follow-up once memberships became the growth focus. I owned the attribution and cohort definitions the channel-level cuts ran on, and the CAC side of the ratio.
Three findings shaped how I think about acquisition:
Most of the difference in LTV between users came from whether they stayed active — not from how much more the retained ones spent. Early behaviour raised predicted LTV mainly by raising the probability of retention.
Simple early behaviours — payments, enrollments, attendances in the first month — predicted retention and long-run value strongly. Enough that value could be estimated early rather than waiting out a full window.
Cohorts weighted toward some paid social channels showed lower early LTV — an effect that narrowed as the measurement window lengthened. Which channel looks efficient depends on the payback window you choose.
The modeling approach they used was a hurdle framework: model retention, then model spend among those retained. That shape — a probability multiplied by a conditional value — is the same one I reach for independently, and it's the backbone of the approach in the rest of this page.
Those findings land directly on where the money goes, and they're the reason a CAC target by itself is a trap.
Explanatory modeling tells you what drives value. Predicting it per user, early enough to act on while a budget decision is still live, is a different problem. Here's how I'd approach that for a freemium subscription business.
Take a consumer subscription product with roughly 100 million registered users and 2 million subscribers. Around 98% of acquired users will never pay anything. That single fact breaks the obvious approach.
If you train one model to predict lifetime value directly across everyone, it learns that the safest prediction is approximately zero. It will score well on average error and be completely useless for growth decisions, because the entire point is identifying the small minority who show early signs of becoming valuable — and the model has been rewarded for ignoring them.
The input window matters as much as the model: predictions run off a user's first 14 days of behaviour — active days, activity volume, whether they subscribed early, plus acquisition cohort, channel and platform. Fourteen days is early enough that the estimate arrives while a budget decision is still live.
Before any feature work, two decisions do most of the damage if you get them wrong: what the target actually is, and how long you have to wait to observe it.
Ninety days isn't lifetime value — it's a window short enough to observe. That substitution needs evidence, not assertion, and the test is straightforward: on historical cohorts, how strongly does the short-window value track the long one?
In the marketplace's own LTV work, 4-month value correlated with 12-month value at roughly 0.87, which is what justified using the shorter target to get a signal faster and include more recent cohorts. A second check governs the input side: about 30 days of post-acquisition behaviour was needed before the features carried enough signal, correlating around 0.82 with the target. Both numbers are the kind of thing I'd re-derive rather than inherit.
| Family | Examples | What it proxies |
|---|---|---|
| Acquisition | cohort, channel, platform, country | Who we bought and when — mostly controls, so behavioural effects are read other things equal |
| Early engagement | active days, sessions, activity volume, time to first meaningful action | Intent. Consistently the strongest family |
| Early monetisation | spend in window, share of discounted spend | Direct value signal, and discount share flags value that may not repeat |
| First experience | first product type, format, price, quality signals | Whether the first encounter was good enough to come back to |
| Lifecycle | email engagement, onboarding responses | Reachability — can we still influence this user |
Simpler to build and maintain, but it collapses toward zero because non-subscribers so heavily outnumber subscribers. Good average error, no discriminating power where it matters.
Just use each channel's observed average. It might tell you TikTok beats Meta, but it assumes every user in a channel behaves identically, ignores what people actually did in their first two weeks, and can't adapt as channels and the market shift.
I'd still start with the two-step model rather than something more sophisticated. It's explainable to a growth team, it can be built and validated quickly, and it establishes the measurement loop. Complexity is worth adding once there's something working to improve on.
Two layers, and they fail differently. First, is each model any good on its own terms. Second, is the combined estimate good enough to spend against.
most answers stop at AUC. this is the part that actually costs money.Test the combined estimate on a recent holdout the model never saw but whose actual 90-day value is already known. Check two separate things — because a model can pass the first and fail the second in a way that costs real money.
Stating these explicitly is part of the deliverable. Each one is a place the model can quietly stop working.
A 90-day window is a practical proxy, not lifetime value. The natural next step is survival analysis — modeling the probability a subscriber is still active at each point in time, and projecting toward full lifetime value instead of stopping at an arbitrary cutoff.
It matters because churn isn't evenly distributed. The first one to three months are the critical period, while people are still deciding whether the product fits their routine. Anyone who survives past that tends to stay much longer. It's the gym membership pattern: most cancellations happen in the first two months, and whoever is still turning up in month three rarely quits. A fixed-window model can't see that changing risk; a survival model is built around it.
Full timestamped event histories — subscription starts, renewals, cancellations, upgrades, downgrades — extending well beyond the 90-day window.
More complexity, a harder model to explain to stakeholders, and more infrastructure to maintain. Worth it for a mature view of LTV; not worth it as the starting point.
A model being accurate on historical data isn't the same as a team being ready to spend against it.
Predicted LTV is an early directional signal, not an automatic budget rule — and it carries a bias worth naming out loud. Because it's built on acquisition channel, it will systematically over-credit lower-funnel channels and under-credit upper-funnel ones, for exactly the same reason last-touch attribution does.
So I'd use it directly for lower-funnel decisions, and pair it with incrementality testing before touching upper-funnel spend — where the model is least trustworthy and a holdout test is the only honest read.
And on value-based bidding: if the estimates prove stable over several cycles, predicted LTV could be tested as a value signal for ROAS optimization in the ad platforms. But that's the destination, not the starting point. Budget guidance first — it's lower risk, it's reversible, and it proves the number is worth trusting before an algorithm starts spending against it.
Without a value number, acquisition optimises for volume at a cost ceiling — and every channel conversation becomes an argument about CAC in isolation. With one, the question becomes which customers are worth more than they cost, which is a strategy question rather than an efficiency one.
The two-step structure isn't specific to one business. Any model where most users are worth nothing and a minority carry the value takes the same shape — and it retrains on new cohorts rather than needing a rebuild. The framework transfers; only the features change.
It's also the piece with the longest dependency chain behind it. Channel-level LTV requires correct channel assignment, which requires working attribution, which requires instrumentation that fires. Getting to a trustworthy LTV:CAC number is the reason the other four projects had to happen first — and why I'd be sceptical of any unit-economics number from a team that hasn't done that groundwork.
Open to senior marketing analytics and data science roles, and select consulting projects.