跳到主要内容

营销活动投入与转化分析

收藏0

分析用户导出的活动数据,计算 ROI、ROAS 和漏斗转化,比较不同渠道的归因结果。

让 AI 安装

github最新版本 0.1.1更新于 2026-10-21下载 2 次2 个版本

scripts/attribution_analyzer.py

12.5 KB
1#!/usr/bin/env python3
2"""
3Attribution Analyzer - Multi-touch attribution modeling for marketing campaigns.
4
5Implements 5 attribution models:
6 - first-touch: 100% credit to first interaction
7 - last-touch: 100% credit to last interaction
8 - linear: Equal credit across all touchpoints
9 - time-decay: Exponential decay favoring recent touchpoints
10 - position-based: 40% first, 40% last, 20% split among middle
11
12Usage:
13 python attribution_analyzer.py data.json
14 python attribution_analyzer.py data.json --model time-decay
15 python attribution_analyzer.py data.json --model time-decay --half-life 14
16 python attribution_analyzer.py data.json --format json
17"""
18
19import argparse
20import json
21import sys
22from datetime import datetime
23from typing import Any, Dict, List, Optional
24
25
26MODELS = ["first-touch", "last-touch", "linear", "time-decay", "position-based"]
27
28
29def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
30 """Safely divide two numbers, returning default if denominator is zero."""
31 if denominator == 0:
32 return default
33 return numerator / denominator
34
35
36def parse_timestamp(ts: str) -> datetime:
37 """Parse an ISO-format timestamp string into a datetime object."""
38 for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
39 try:
40 return datetime.strptime(ts, fmt)
41 except ValueError:
42 continue
43 raise ValueError(f"Cannot parse timestamp: {ts}")
44
45
46def first_touch_attribution(journeys: List[Dict]) -> Dict[str, float]:
47 """First-touch: 100% credit to the first touchpoint in each journey."""
48 credits: Dict[str, float] = {}
49 for journey in journeys:
50 if not journey.get("converted", False):
51 continue
52 touchpoints = journey.get("touchpoints", [])
53 if not touchpoints:
54 continue
55 sorted_tp = sorted(touchpoints, key=lambda t: parse_timestamp(t["timestamp"]))
56 channel = sorted_tp[0]["channel"]
57 revenue = journey.get("revenue", 1.0)
58 credits[channel] = credits.get(channel, 0.0) + revenue
59 return credits
60
61
62def last_touch_attribution(journeys: List[Dict]) -> Dict[str, float]:
63 """Last-touch: 100% credit to the last touchpoint in each journey."""
64 credits: Dict[str, float] = {}
65 for journey in journeys:
66 if not journey.get("converted", False):
67 continue
68 touchpoints = journey.get("touchpoints", [])
69 if not touchpoints:
70 continue
71 sorted_tp = sorted(touchpoints, key=lambda t: parse_timestamp(t["timestamp"]))
72 channel = sorted_tp[-1]["channel"]
73 revenue = journey.get("revenue", 1.0)
74 credits[channel] = credits.get(channel, 0.0) + revenue
75 return credits
76
77
78def linear_attribution(journeys: List[Dict]) -> Dict[str, float]:
79 """Linear: Equal credit split across all touchpoints in each journey."""
80 credits: Dict[str, float] = {}
81 for journey in journeys:
82 if not journey.get("converted", False):
83 continue
84 touchpoints = journey.get("touchpoints", [])
85 if not touchpoints:
86 continue
87 revenue = journey.get("revenue", 1.0)
88 share = safe_divide(revenue, len(touchpoints))
89 for tp in touchpoints:
90 channel = tp["channel"]
91 credits[channel] = credits.get(channel, 0.0) + share
92 return credits
93
94
95def time_decay_attribution(journeys: List[Dict], half_life_days: float = 7.0) -> Dict[str, float]:
96 """Time-decay: Exponential decay giving more credit to recent touchpoints.
97
98 Uses a configurable half-life (in days). Touchpoints closer to conversion
99 receive exponentially more credit.
100 """
101 import math
102
103 credits: Dict[str, float] = {}
104 decay_rate = math.log(2) / half_life_days
105
106 for journey in journeys:
107 if not journey.get("converted", False):
108 continue
109 touchpoints = journey.get("touchpoints", [])
110 if not touchpoints:
111 continue
112
113 revenue = journey.get("revenue", 1.0)
114 sorted_tp = sorted(touchpoints, key=lambda t: parse_timestamp(t["timestamp"]))
115 conversion_time = parse_timestamp(sorted_tp[-1]["timestamp"])
116
117 # Calculate raw weights
118 weights: List[float] = []
119 for tp in sorted_tp:
120 tp_time = parse_timestamp(tp["timestamp"])
121 days_before = (conversion_time - tp_time).total_seconds() / 86400.0
122 weight = math.exp(-decay_rate * days_before)
123 weights.append(weight)
124
125 total_weight = sum(weights)
126 if total_weight == 0:
127 continue
128
129 for i, tp in enumerate(sorted_tp):
130 channel = tp["channel"]
131 share = safe_divide(weights[i], total_weight) * revenue
132 credits[channel] = credits.get(channel, 0.0) + share
133
134 return credits
135
136
137def position_based_attribution(journeys: List[Dict]) -> Dict[str, float]:
138 """Position-based: 40% first, 40% last, 20% split among middle touchpoints."""
139 credits: Dict[str, float] = {}
140 for journey in journeys:
141 if not journey.get("converted", False):
142 continue
143 touchpoints = journey.get("touchpoints", [])
144 if not touchpoints:
145 continue
146
147 revenue = journey.get("revenue", 1.0)
148 sorted_tp = sorted(touchpoints, key=lambda t: parse_timestamp(t["timestamp"]))
149
150 if len(sorted_tp) == 1:
151 channel = sorted_tp[0]["channel"]
152 credits[channel] = credits.get(channel, 0.0) + revenue
153 elif len(sorted_tp) == 2:
154 first_channel = sorted_tp[0]["channel"]
155 last_channel = sorted_tp[-1]["channel"]
156 credits[first_channel] = credits.get(first_channel, 0.0) + revenue * 0.5
157 credits[last_channel] = credits.get(last_channel, 0.0) + revenue * 0.5
158 else:
159 first_channel = sorted_tp[0]["channel"]
160 last_channel = sorted_tp[-1]["channel"]
161 credits[first_channel] = credits.get(first_channel, 0.0) + revenue * 0.4
162 credits[last_channel] = credits.get(last_channel, 0.0) + revenue * 0.4
163
164 middle_count = len(sorted_tp) - 2
165 middle_share = safe_divide(revenue * 0.2, middle_count)
166 for tp in sorted_tp[1:-1]:
167 channel = tp["channel"]
168 credits[channel] = credits.get(channel, 0.0) + middle_share
169
170 return credits
171
172
173def run_model(model_name: str, journeys: List[Dict], half_life: float = 7.0) -> Dict[str, float]:
174 """Dispatch to the appropriate attribution model."""
175 if model_name == "first-touch":
176 return first_touch_attribution(journeys)
177 elif model_name == "last-touch":
178 return last_touch_attribution(journeys)
179 elif model_name == "linear":
180 return linear_attribution(journeys)
181 elif model_name == "time-decay":
182 return time_decay_attribution(journeys, half_life)
183 elif model_name == "position-based":
184 return position_based_attribution(journeys)
185 else:
186 raise ValueError(f"Unknown model: {model_name}. Choose from: {', '.join(MODELS)}")
187
188
189def compute_summary(journeys: List[Dict]) -> Dict[str, Any]:
190 """Compute summary statistics about the journey data."""
191 total_journeys = len(journeys)
192 converted = sum(1 for j in journeys if j.get("converted", False))
193 total_revenue = sum(j.get("revenue", 0.0) for j in journeys if j.get("converted", False))
194 all_channels = set()
195 for j in journeys:
196 for tp in j.get("touchpoints", []):
197 all_channels.add(tp["channel"])
198
199 return {
200 "total_journeys": total_journeys,
201 "converted_journeys": converted,
202 "conversion_rate": round(safe_divide(converted, total_journeys) * 100, 2),
203 "total_revenue": round(total_revenue, 2),
204 "channels_observed": sorted(all_channels),
205 }
206
207
208def format_text(results: Dict[str, Any]) -> str:
209 """Format results as human-readable text."""
210 lines: List[str] = []
211 lines.append("=" * 70)
212 lines.append("MULTI-TOUCH ATTRIBUTION ANALYSIS")
213 lines.append("=" * 70)
214
215 summary = results["summary"]
216 lines.append("")
217 lines.append("SUMMARY")
218 lines.append(f" Total Journeys: {summary['total_journeys']}")
219 lines.append(f" Converted: {summary['converted_journeys']}")
220 lines.append(f" Conversion Rate: {summary['conversion_rate']}%")
221 lines.append(f" Total Revenue: ${summary['total_revenue']:,.2f}")
222 lines.append(f" Channels Observed: {', '.join(summary['channels_observed'])}")
223
224 for model_name, credits in results["models"].items():
225 lines.append("")
226 lines.append("-" * 70)
227 lines.append(f"MODEL: {model_name.upper()}")
228 lines.append("-" * 70)
229
230 if not credits:
231 lines.append(" No conversions to attribute.")
232 continue
233
234 total_credit = sum(credits.values())
235 sorted_channels = sorted(credits.items(), key=lambda x: x[1], reverse=True)
236
237 lines.append(f" {'Channel':<25} {'Revenue Credit':>15} {'Share':>10}")
238 lines.append(f" {'-'*25} {'-'*15} {'-'*10}")
239
240 for channel, credit in sorted_channels:
241 pct = safe_divide(credit, total_credit) * 100
242 lines.append(f" {channel:<25} ${credit:>13,.2f} {pct:>8.1f}%")
243
244 lines.append(f" {'TOTAL':<25} ${total_credit:>13,.2f} {'100.0%':>10}")
245
246 # Comparison table
247 if len(results["models"]) > 1:
248 lines.append("")
249 lines.append("=" * 70)
250 lines.append("CROSS-MODEL COMPARISON")
251 lines.append("=" * 70)
252
253 all_channels = set()
254 for credits in results["models"].values():
255 all_channels.update(credits.keys())
256 all_channels_sorted = sorted(all_channels)
257
258 model_names = list(results["models"].keys())
259 header = f" {'Channel':<20}"
260 for mn in model_names:
261 short = mn.replace("-", " ").title()
262 header += f" {short:>14}"
263 lines.append(header)
264 lines.append(f" {'-'*20}" + f" {'-'*14}" * len(model_names))
265
266 for ch in all_channels_sorted:
267 row = f" {ch:<20}"
268 for mn in model_names:
269 val = results["models"][mn].get(ch, 0.0)
270 row += f" ${val:>12,.2f}"
271 lines.append(row)
272
273 lines.append("")
274 return "\n".join(lines)
275
276
277def main() -> None:
278 """Main entry point for the attribution analyzer."""
279 parser = argparse.ArgumentParser(
280 description="Multi-touch attribution analyzer for marketing campaigns.",
281 epilog="Example: python attribution_analyzer.py data.json --model linear --format json",
282 )
283 parser.add_argument(
284 "input_file",
285 help="Path to JSON file containing journey/touchpoint data",
286 )
287 parser.add_argument(
288 "--model",
289 choices=MODELS,
290 default=None,
291 help="Run a specific attribution model (default: run all 5 models)",
292 )
293 parser.add_argument(
294 "--half-life",
295 type=float,
296 default=7.0,
297 help="Half-life in days for time-decay model (default: 7)",
298 )
299 parser.add_argument(
300 "--format",
301 choices=["json", "text"],
302 default="text",
303 dest="output_format",
304 help="Output format (default: text)",
305 )
306
307 args = parser.parse_args()
308
309 # Load input data
310 try:
311 with open(args.input_file, "r") as f:
312 data = json.load(f)
313 except FileNotFoundError:
314 print(f"Error: File not found: {args.input_file}", file=sys.stderr)
315 sys.exit(1)
316 except json.JSONDecodeError as e:
317 print(f"Error: Invalid JSON in {args.input_file}: {e}", file=sys.stderr)
318 sys.exit(1)
319
320 journeys = data.get("journeys", [])
321 if not journeys:
322 print("Error: No 'journeys' array found in input data.", file=sys.stderr)
323 sys.exit(1)
324
325 # Determine which models to run
326 models_to_run = [args.model] if args.model else MODELS
327
328 # Run models
329 model_results: Dict[str, Dict[str, float]] = {}
330 for model_name in models_to_run:
331 credits = run_model(model_name, journeys, args.half_life)
332 model_results[model_name] = {ch: round(v, 2) for ch, v in credits.items()}
333
334 # Build output
335 results: Dict[str, Any] = {
336 "summary": compute_summary(journeys),
337 "models": model_results,
338 }
339
340 if args.output_format == "json":
341 print(json.dumps(results, indent=2))
342 else:
343 print(format_text(results))
344
345
346if __name__ == "__main__":
347 main()

文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。