{
  "version": 1,
  "id": "sql-funnel-analysis",
  "title": "Funnel Analysis",
  "summary": "Measure step-to-step conversion and find the drop-off in an ordered event funnel.",
  "estimatedMinutes": 30,
  "prerequisites": [],
  "source": "courses-notebook DS301_databases_sql 11_funnel_analysis",
  "lesson": "Track users through ordered steps, identify bottlenecks, and A/B test improvements.\n\n```python\nimport sqlite3, pandas as pd, numpy as np, matplotlib.pyplot as plt\nprint('Funnel Analysis')\n```\n\n## Key Concepts\n\n### Funnel Definition\n\nA **conversion funnel** is an ordered sequence of steps. Each step has:\n- **Entry count** \u2014 users who reached this step\n- **Completion count** \u2014 users who completed it\n- **Step CVR** = completions / entries\n- **Overall CVR** = final completions / step-1 entries\n\n### SQL Pattern\n\n```sql\nSELECT\n    COUNT(DISTINCT CASE WHEN step_num >= 1 THEN user_id END) AS step1,\n    COUNT(DISTINCT CASE WHEN step_num >= 2 THEN user_id END) AS step2,\n    COUNT(DISTINCT CASE WHEN step_num >= 3 THEN user_id END) AS step3\nFROM funnel_events\n```\n\n### A/B Split in Funnels\n\n$$\\text{lift} = \\frac{\\text{CVR}_B - \\text{CVR}_A}{\\text{CVR}_A}$$\n\nUse **Fisher's exact test** for statistical significance on count data.\n\n### Prioritising Drop-off\n\nFocus on the step with the largest **absolute** user loss (1,000 lost at 80% > 50 lost at 20%).\n\n```python\nimport sqlite3, pandas as pd, numpy as np, matplotlib.pyplot as plt\nfrom scipy.stats import fisher_exact\n\nrng = np.random.default_rng(42)\nconn = sqlite3.connect(\":memory:\")\n\nsteps = [\"Landing\",\"Product View\",\"Add to Cart\",\"Checkout\",\"Purchase\"]\nrates_a = [1.0, 0.60, 0.40, 0.70, 0.65]\nrates_b = [1.0, 0.62, 0.45, 0.72, 0.70]\nn = 1000\n\nrows = []\nfor uid in range(n*2):\n    var = \"A\" if uid < n else \"B\"\n    rates = rates_a if var==\"A\" else rates_b\n    active = True\n    for i,(step,p) in enumerate(zip(steps,rates)):\n        if not active: break\n        if rng.random() < p:\n            rows.append((uid, var, step, i+1))\n        else:\n            active = False\n\nconn.execute(\"CREATE TABLE funnel (user_id INT, variant TEXT, step TEXT, step_num INT)\")\nconn.executemany(\"INSERT INTO funnel VALUES (?,?,?,?)\", rows)\nconn.commit()\n\ndf = pd.read_sql(\"\"\"\nSELECT variant, step_num, step, COUNT(DISTINCT user_id) AS users\nFROM funnel GROUP BY variant, step_num, step ORDER BY variant, step_num\n\"\"\", conn)\n\nfig, axes = plt.subplots(1,3,figsize=(15,5))\nfor vi, var in enumerate([\"A\",\"B\"]):\n    grp = df[df[\"variant\"]==var].sort_values(\"step_num\")\n    total = grp[\"users\"].iloc[0]\n    grp = grp.copy(); grp[\"pct\"] = grp[\"users\"]/total*100\n    col = \"#3498db\" if var==\"A\" else \"#e74c3c\"\n    bars = axes[vi].barh(grp[\"step\"][::-1], grp[\"pct\"][::-1], color=col, alpha=0.75)\n    for bar, (_, row) in zip(bars, grp[::-1].iterrows()):\n        axes[vi].text(bar.get_width()+1, bar.get_y()+bar.get_height()/2,\n                f\"{row['pct']:.1f}% ({int(row['users'])})\", va=\"center\", fontsize=9)\n    axes[vi].set_xlim(0,115); axes[vi].set_title(f\"Variant {var} Funnel\")\n    axes[vi].set_xlabel(\"% of entrants\")\n    axes[vi].spines[\"top\"].set_visible(False); axes[vi].spines[\"right\"].set_visible(False)\n\ncmp = df.pivot_table(index=[\"step\",\"step_num\"],columns=\"variant\",values=\"users\").reset_index().sort_values(\"step_num\")\nx = np.arange(len(cmp)); w=0.35; ta=n; tb=n\naxes[2].bar(x-w/2, cmp[\"A\"]/ta*100, w, label=\"A\", color=\"#3498db\", alpha=0.8)\naxes[2].bar(x+w/2, cmp[\"B\"]/tb*100, w, label=\"B\", color=\"#e74c3c\", alpha=0.8)\naxes[2].set_xticks(x); axes[2].set_xticklabels(cmp[\"step\"], rotation=25, ha=\"right\", fontsize=8)\naxes[2].set_title(\"A/B Comparison\"); axes[2].legend(); axes[2].grid(axis=\"y\",alpha=0.3)\naxes[2].spines[\"top\"].set_visible(False); axes[2].spines[\"right\"].set_visible(False)\nplt.tight_layout(); plt.show()\n\na_conv = len([r for r in rows if r[1]==\"A\" and r[3]==5])\nb_conv = len([r for r in rows if r[1]==\"B\" and r[3]==5])\n_, pval = fisher_exact([[a_conv,n-a_conv],[b_conv,n-b_conv]])\nprint(f\"Final CVR  A:{a_conv/n:.3f}  B:{b_conv/n:.3f}  p={pval:.4f} {'*significant*' if pval<0.05 else 'not significant'}\")\nconn.close()\n```\n\n```python\n# Data analysis solution checker\n\nimport pandas as pd\nimport numpy as np\n\ndef summarise_df(df, label=\"DataFrame\"):\n    \"\"\"Quick sanity check for a cleaned dataframe.\"\"\"\n    print(f\"=== {label} ===\")\n    print(f\"  Shape: {df.shape}\")\n    print(f\"  Missing: {df.isnull().sum().sum()} values ({df.isnull().mean().mean():.1%})\")\n    print(f\"  Dtypes: {df.dtypes.value_counts().to_dict()}\")\n    if df.select_dtypes('number').shape[1] > 0:\n        num = df.select_dtypes('number')\n        print(f\"  Numeric range: [{num.values.min():.2f}, {num.values.max():.2f}]\")\n    dupes = df.duplicated().sum()\n    print(f\"  Duplicates: {dupes}\")\n\ndef check_clean(df):\n    \"\"\"Assert DataFrame is clean: no NaN, no dupes, correct dtypes.\"\"\"\n    issues = []\n    if df.isnull().any().any():\n        issues.append(f\"{df.isnull().sum().sum()} missing values remain\")\n    if df.duplicated().any():\n        issues.append(f\"{df.duplicated().sum()} duplicate rows remain\")\n    if issues:\n        print(\"\u2717 ISSUES: \" + \"; \".join(issues))\n    else:\n        print(\"\u2713 DataFrame is clean\")\n\n# Demo\nnp.random.seed(0)\ndf = pd.DataFrame({\n    'age':    [25, 30, np.nan, 22, 35, 30],\n    'salary': [50000, 60000, 75000, 45000, None, 60000],\n    'dept':   ['Eng', 'HR', 'Eng', 'Sales', 'HR', 'HR'],\n})\nsummarise_df(df, \"Raw\")\ndf_clean = df.dropna().drop_duplicates()\nsummarise_df(df_clean, \"Cleaned\")\ncheck_clean(df_clean)\n```\n\n```python\n# Advanced: Pandas performance patterns\n\nimport pandas as pd\nimport numpy as np\nimport timeit\n\nnp.random.seed(42)\nn = 100_000\ndf = pd.DataFrame({\n    'group':   np.random.choice(list('ABCDE'), n),\n    'value':   np.random.randn(n),\n    'flag':    np.random.randint(0, 2, n).astype(bool),\n    'amount':  np.random.exponential(100, n),\n})\n\ndef time_it(label, fn):\n    t = timeit.timeit(fn, number=10) / 10 * 1000\n    print(f\"  {label:<40}: {t:.2f}ms\")\n\nprint(\"Pandas performance patterns:\")\n\n# 1. apply vs vectorised operation\ntime_it(\"df['value'].apply(lambda x: x**2)\", lambda: df['value'].apply(lambda x: x**2))\ntime_it(\"df['value'] ** 2  (vectorised)\", lambda: df['value'] ** 2)\n\n# 2. iterrows vs vectorised groupby\ntime_it(\"groupby().mean()\", lambda: df.groupby('group')['amount'].mean())\ntime_it(\"groupby().agg({'amount': ['mean', 'std', 'count']})\",\n        lambda: df.groupby('group')['amount'].agg(['mean', 'std', 'count']))\n\n# 3. Boolean indexing vs query\ntime_it(\"df[df.flag & (df.value > 0)]\",\n        lambda: df[df['flag'] & (df['value'] > 0)])\ntime_it(\"df.query('flag and value > 0')\",\n        lambda: df.query('flag and value > 0'))\n\nprint(\"\\nKey insight: always prefer vectorised ops over .apply() or .iterrows()\")\n```\n\n## Databases & SQL Quick Reference\n\n**Query optimisation checklist:**\n1. `EXPLAIN ANALYZE` \u2014 find seq scans and high cost nodes\n2. Add index on filter/join columns\n3. Rewrite correlated subqueries as JOINs or CTEs\n4. Use `LIMIT` + keyset pagination instead of `OFFSET`\n5. Vacuum/analyse for stale statistics\n\n**Index decision:**\n| Situation | Index type |\n|-----------|-----------|\n| Equality + range | B-Tree (default) |\n| Full-text search | GIN with `tsvector` |\n| JSONB keys | GIN |\n| Geospatial | GiST |\n| Sequential insert, range scan | BRIN |\n\n**Transaction isolation levels:**\n```sql\nSET TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- default Postgres\n-- REPEATABLE READ  \u2192 no phantom reads for single txn\n-- SERIALIZABLE     \u2192 full isolation, slowest\n```\n\n## Summary & Key Takeaways\n\n- Focus optimisation on the step with the largest **absolute** user drop, not the lowest conversion rate.\n- Step conversion = completions / entrants at that step; overall conversion = final completions / step-1 entrants.\n- A/B test funnel variants with Fisher's exact test or chi-squared for statistical significance.\n- Session-based funnels (strict ordering within a session) are more precise than looser multi-session funnels.\n- Segment funnels by device, channel, and user type \u2014 the same bottleneck often has different root causes across segments.\n\n## \ud83e\udde0 Quick Quiz\n\n**Q1.** A conversion funnel tracks:\n- A) Revenue per user\n- B) **The percentage of users completing each step in a defined sequence of actions** \u2713\n- C) Session duration\n- D) Page views\n\n**Q2.** Drop-off rate at funnel step n is:\n- A) Users at step n divided by total users\n- B) **Users who completed step n-1 but not step n, as a fraction of step n-1** \u2713\n- C) Bounce rate at step n\n- D) Time spent at step n\n\n**Q3.** Ordered funnel analysis in SQL requires:\n- A) PIVOT operations\n- B) **Window functions with LEAD/LAG to verify event sequence and ordering** \u2713\n- C) Recursive CTEs\n- D) CASE WHEN aggregations only\n\n<details><summary>Answers</summary>1-B, 2-B, 3-B</details>\n",
  "quiz": []
}
