How the numbers on this site are actually calculated.
Two real algorithms power the most interesting views: a Markov-chain attribution model and a logistic-regression conversion-probability model. Both run on the mock dataset at build time. This page walks through the math, the code, and the outputs.
1. Markov-chain attribution
Marketing attribution asks: when a conversion happens after a sequence of touchpoints, how much credit does each touchpoint deserve? Last-click gives all credit to the final touchpoint and is the default in most ad platforms — which makes it structurally biased.
We build a first-order Markov chain over the customer journey channels. The states are START, CONVERT, NULL, and one state per channel that appears in the data.
From each journey we record transitions: START → ch1 → ch2 → ... → CONVERT (or NULL). Normalising the counts gives a transition matrix.
CONVERT and NULL are absorbing states. For an absorbing Markov chain with transition matrix split as P = [[Q, R], [0, I]], the fundamental matrix is N = (I − Q)⁻¹ and the absorption probabilities are B = N · R. The cell B[START][CONVERT] is the baseline probability that a typical journey reaches a conversion.
Removal effect. To find the attribution weight for a channel C, we rebuild the chain with every transition through C redirected to NULL, recompute the conversion probability, and measure how much it dropped: RE(C) = p_baseline − p_without_C. Normalising RE across channels gives shares that sum to 1.
Reference: Anderl, Becker, Hofstede, von Wangenheim — "Mapping the Customer Journey: a graph-based framework for attribution modelling" (2016).
| Channel | Removal effect | Markov share |
|---|---|---|
| 0.1755 | 36.1% | |
| meta | 0.0967 | 19.9% |
| referral | 0.0708 | 14.6% |
| organic | 0.0461 | 9.5% |
| tiktok | 0.0366 | 7.5% |
| direct | 0.0319 | 6.5% |
function convertProbability(chain: AbsorbingChain): number {
// Fundamental matrix N = (I - Q)^-1
const n = chain.transient.length;
const I = identity(n);
const N = invert(subtract(I, chain.Q));
// Absorption probabilities B = N · R
const B = multiply(N, chain.R);
const startIdx = chain.transient.indexOf(START);
const convertIdx = chain.absorbing.indexOf(CONVERT);
return B[startIdx][convertIdx];
}
export function computeMarkovAttribution(journeys) {
const probs = normalize(buildTransitions(journeys));
const chain = buildAbsorbing(probs);
const baseline = convertProbability(chain);
const removal = {};
for (const ch of channelsInChain) {
const reduced = removeChannel(chain, ch);
removal[ch] = Math.max(0, baseline - convertProbability(reduced));
}
return normalize(removal);
}2. Customer conversion scoring
Every customer on the /customers page has a predicted conversion probability. This comes from a binary logistic-regression model trained at data-generation time on the mock dataset.
For each customer we build a feature vector — one-hot first-touch channel + log-scaled session count, pageviews, engagement events and recency. The target is converted ∈ {0, 1}.
We standardize the log-scaled features (subtract mean, divide by std) and train with batch gradient descent on the negative log-likelihood:
L(w) = -1/n · Σᵢ [yᵢ · log σ(z) + (1 − yᵢ) · log(1 − σ(z))] + λ/2 · ||w||² where z = w₀ + Σⱼ wⱼ · xⱼ and σ(z) = 1 / (1 + e^(−z))
Hyperparameters: learning rate 0.1, L2 regularization λ=0.01, 400 epochs. Final training metrics on this run:
Accuracy is not a great metric on its own because the base rate is 34.6% — a model that always predicts "won't convert" would also score high. The log-loss + the per-customer probability calibration on the profile page are the more informative outputs.
for (let epoch = 0; epoch < epochs; epoch++) {
const grad = new Array(numFeatures + 1).fill(0);
for (let i = 0; i < n; i++) {
let z = weights[0];
for (let j = 0; j < numFeatures; j++) z += weights[j + 1] * X[i][j];
const p = sigmoid(z);
const err = p - y[i];
grad[0] += err;
for (let j = 0; j < numFeatures; j++) grad[j + 1] += err * X[i][j];
}
for (let j = 0; j <= numFeatures; j++) {
grad[j] /= n;
if (j > 0) grad[j] += lambda * weights[j];
weights[j] -= lr * grad[j];
}
}3. Funnel math
All funnel numbers are derived from the raw event stream, not stored as pre-aggregated metrics. For each channel we count: paid impressions (ad-server), sessions (GA4/PostHog), engaged sessions (PostHog rule), qualified actions (custom CRM event), conversions (CRM purchase event), repeat customers (≥ 2 conversions).
The Session → Engaged rule is duration > 10s and events > 1, consistent with GA4's default engaged-session definition.
4. In production
The pages here are powered by static JSON snapshots generated at build time. In a real deployment the data layer would be:
- Google Ads APIDaily spend, clicks, impressions per campaign/ad_group/creative
- Meta Marketing APISame shape as Google, plus creative metadata + thumbnails
- TikTok Marketing APISame shape; report endpoints for spend and breakdowns
- LinkedIn Ads APICampaign management + analytics endpoints
- Snapchat Marketing APISame shape
- PostHog events APICustom events from the website + product
- GA4 Data APISession-level metrics and traffic source attribution
- CRM (Salesforce / HubSpot / custom)Customers, owners, qualified actions, conversions
Fivetran / Stitch / Airbyte (or first-party connectors) land each source into the warehouse hourly. dbt models normalize the schema across platforms (the channel_metrics view, the customer_journeys mart, etc.). The Markov + scoring jobs run on a daily Airflow / Dagster / Prefect schedule and update the same JSON shapes the app reads here.
Every accessor in src/lib/data.ts includes a comment showing the equivalent SQL query you'd issue against the warehouse — these are the exact integration points where credentials and endpoint URLs plug in for a real deployment.