reporting.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. """调控结果 Excel 报告生成。"""
  2. from __future__ import annotations
  3. from pathlib import Path
  4. from typing import Dict, Mapping, Sequence
  5. import numpy as np
  6. import pandas as pd
  7. from openpyxl import Workbook
  8. from openpyxl.formatting.rule import ColorScaleRule
  9. from openpyxl.styles import Alignment, Font, PatternFill
  10. from openpyxl.utils import get_column_letter
  11. from .fission_multiplier import DISPLAY_MULTIPLIER_COLUMN
  12. from .metrics import ENTITY_GZH, ENTITY_QIWEI, ENTITY_SELF
  13. HEADER_FILL = PatternFill("solid", fgColor="1F4E78")
  14. HEADER_FONT = Font(color="FFFFFF", bold=True)
  15. STOP_FILL = PatternFill("solid", fgColor="F4CCCC")
  16. UP_FILL = PatternFill("solid", fgColor="D9EAD3")
  17. ADJUST_FILL = PatternFill("solid", fgColor="FFF2CC")
  18. REPORT_VERSION = "roi_report_v4"
  19. REPORT_RUN_SUFFIX = "r4"
  20. BASE_COLUMNS: Dict[str, Sequence[str]] = {
  21. "小程序投流": (
  22. "渠道",
  23. "代理名称",
  24. "账号id",
  25. "广告id",
  26. "广告名称",
  27. "包名",
  28. "广告优化目标",
  29. "创意id",
  30. "广告age",
  31. "日均首层UV",
  32. "首层效率收入",
  33. "T0裂变效率收入",
  34. "LTV预测效率收入",
  35. "日均成本",
  36. DISPLAY_MULTIPLIER_COLUMN,
  37. "预测ROI",
  38. "动作",
  39. "关停线",
  40. ),
  41. "公众号即转": (
  42. "渠道",
  43. "合作方名",
  44. "公众号名",
  45. "日均首层UV",
  46. "首层效率收入",
  47. "T0裂变效率收入",
  48. "LTV预测效率收入",
  49. "日均成本",
  50. DISPLAY_MULTIPLIER_COLUMN,
  51. "预测ROI",
  52. "动作",
  53. "关停线",
  54. ),
  55. "企微群合作": (
  56. "渠道",
  57. "合作方名",
  58. "日均首层UV",
  59. "首层效率收入",
  60. "T0裂变效率收入",
  61. "LTV预测效率收入",
  62. "日均成本",
  63. DISPLAY_MULTIPLIER_COLUMN,
  64. "传播裂变参数状态",
  65. "调控参与状态",
  66. "预测ROI",
  67. "动作",
  68. "关停线",
  69. ),
  70. }
  71. ENTITY_TO_SHEET = {
  72. ENTITY_SELF: "小程序投流",
  73. ENTITY_GZH: "公众号即转",
  74. ENTITY_QIWEI: "企微群合作",
  75. }
  76. FREEZE_PANES = {
  77. "小程序投流": "F2",
  78. "公众号即转": "D2",
  79. "企微群合作": "C2",
  80. }
  81. def _sheet_frame(candidates: pd.DataFrame, sheet_name: str) -> pd.DataFrame:
  82. entity_type = next(
  83. key for key, value in ENTITY_TO_SHEET.items() if value == sheet_name
  84. )
  85. subset = candidates[candidates["entity_type"].eq(entity_type)].copy()
  86. subset = subset.rename(columns={"channel": "渠道"})
  87. coverage_source = (
  88. subset["覆盖天数"]
  89. if "覆盖天数" in subset
  90. else pd.Series(3, index=subset.index)
  91. )
  92. coverage_days = pd.to_numeric(
  93. coverage_source, errors="coerce"
  94. ).replace(0, np.nan)
  95. subset["日均首层UV"] = pd.to_numeric(
  96. subset.get("日均首层UV"), errors="coerce"
  97. ).round().astype("Int64")
  98. subset["首层效率收入"] = (
  99. pd.to_numeric(subset.get("效率收入"), errors="coerce")
  100. / coverage_days
  101. )
  102. subset["T0裂变效率收入"] = (
  103. pd.to_numeric(subset.get("T0实际裂变收入"), errors="coerce")
  104. / coverage_days
  105. )
  106. subset["LTV预测效率收入"] = (
  107. pd.to_numeric(
  108. subset.get("预测全链路效率收入"), errors="coerce"
  109. )
  110. / coverage_days
  111. )
  112. subset["日均成本"] = (
  113. pd.to_numeric(subset.get("成本"), errors="coerce") / coverage_days
  114. )
  115. subset["预测ROI"] = subset["ROI"]
  116. subset["关停线"] = subset["t_stop"]
  117. subset["扩量线"] = subset["t_up"]
  118. visible_columns = list(BASE_COLUMNS[sheet_name])
  119. for column in visible_columns:
  120. if column not in subset:
  121. subset[column] = ""
  122. hidden_columns = [
  123. column for column in subset.columns if column not in visible_columns
  124. ]
  125. ordered = visible_columns + hidden_columns
  126. subset = subset[ordered]
  127. if not subset.empty:
  128. subset = subset.sort_values("预测ROI", ascending=True)
  129. return subset
  130. def _write_dataframe(ws, frame: pd.DataFrame) -> None:
  131. ws.append(list(frame.columns))
  132. for values in frame.itertuples(index=False, name=None):
  133. ws.append(
  134. [
  135. None if isinstance(value, float) and np.isnan(value) else value
  136. for value in values
  137. ]
  138. )
  139. def _format_sheet(ws, sheet_name: str) -> None:
  140. max_column = max(ws.max_column, 1)
  141. max_row = max(ws.max_row, 1)
  142. ws.freeze_panes = FREEZE_PANES[sheet_name]
  143. ws.auto_filter.ref = f"A1:{get_column_letter(max_column)}{max_row}"
  144. ws.row_dimensions[1].height = 28
  145. headers = {}
  146. for cell in ws[1]:
  147. cell.fill = HEADER_FILL
  148. cell.font = HEADER_FONT
  149. cell.alignment = Alignment(horizontal="center", vertical="center")
  150. headers[cell.value] = cell.column
  151. for column_index in range(1, max_column + 1):
  152. header = ws.cell(1, column_index).value or ""
  153. values = [str(ws.cell(row, column_index).value or "") for row in range(1, max_row + 1)]
  154. width = min(max(max(map(len, values)), len(str(header))) + 2, 42)
  155. ws.column_dimensions[get_column_letter(column_index)].width = max(width, 11)
  156. if "ROI" in str(header) or "裂变率" in str(header) or "百分位" in str(header):
  157. for row in range(2, max_row + 1):
  158. ws.cell(row, column_index).number_format = "0.000"
  159. elif "成本" in str(header) or header in {
  160. "首层效率收入",
  161. "T0裂变效率收入",
  162. "LTV预测效率收入",
  163. "效率收入",
  164. "T0实际裂变收入",
  165. "实际全链路效率收入",
  166. "预测T1-T15裂变收入",
  167. "预测T0-T15裂变收入",
  168. "预测全链路效率收入",
  169. }:
  170. for row in range(2, max_row + 1):
  171. ws.cell(row, column_index).number_format = "#,##0.00"
  172. elif "UV" in str(header) or "裂变数" in str(header):
  173. for row in range(2, max_row + 1):
  174. ws.cell(row, column_index).number_format = "#,##0"
  175. if "ROI" in str(header) and max_row >= 2:
  176. letter = get_column_letter(column_index)
  177. ws.conditional_formatting.add(
  178. f"{letter}2:{letter}{max_row}",
  179. ColorScaleRule(
  180. start_type="min",
  181. start_color="F8696B",
  182. mid_type="percentile",
  183. mid_value=50,
  184. mid_color="FFEB84",
  185. end_type="max",
  186. end_color="63BE7B",
  187. ),
  188. )
  189. if column_index > len(BASE_COLUMNS[sheet_name]):
  190. ws.column_dimensions[get_column_letter(column_index)].hidden = True
  191. action_column = headers.get("动作")
  192. if action_column:
  193. for row in range(2, max_row + 1):
  194. action = ws.cell(row, action_column).value
  195. fill = {
  196. "关停": STOP_FILL,
  197. "扩量": UP_FILL,
  198. "调整封面&落地页视频": ADJUST_FILL,
  199. }.get(action)
  200. if fill:
  201. ws.cell(row, action_column).fill = fill
  202. def _write_summary(
  203. workbook: Workbook,
  204. thresholds: pd.DataFrame,
  205. candidates: pd.DataFrame,
  206. expected_dates: Sequence[str],
  207. rule_config: Mapping[str, object] | None,
  208. ) -> None:
  209. ws = workbook.create_sheet("运行摘要")
  210. self_min_uv = (rule_config or {}).get("self_min_avg_uv", 200)
  211. partner_min_uv = (rule_config or {}).get("partner_min_avg_uv", 200)
  212. min_daily_cost = (rule_config or {}).get("min_daily_cost", 100)
  213. rows = [
  214. ("统计窗口", f"{expected_dates[0]} 至 {expected_dates[-1]}"),
  215. ("首层口径", "usersharedepth='0'"),
  216. ("UV口径", "COUNT(DISTINCT mid)"),
  217. (
  218. "ROI口径",
  219. "T0裂变收入为实际值;仅预测T1-T15增量。预测总收入=首层实际效率收入+T0实际裂变收入+T0实际裂变收入×(传播裂变系数-对T0裂变-1)",
  220. ),
  221. (
  222. "企微口径",
  223. "企微按渠道/合作方保留展示,临时参考系数为2.5,待补算正式参数;不进入阈值样本池和调控。",
  224. ),
  225. ("阈值口径", "小程序和公众号合格实体进入同一全局池:三日聚合ROI的P10=关停线、P80=扩量线;企微不参与"),
  226. (
  227. "阈值样本条件",
  228. f"连续3天数据完整;小程序日均首层UV>{self_min_uv:g};"
  229. f"公众号日均首层UV>{partner_min_uv:g};连续3天每天成本>"
  230. f"{min_daily_cost:g};三日成本>0且预测ROI有效;企微仅展示、不进入样本池",
  231. ),
  232. ("动作优先级", "关停 > 扩量 > 调整封面&落地页视频"),
  233. ("审批方式", "飞书消息整批确认或拒绝;表格仅用于查看,不做逐行审批"),
  234. ]
  235. for label, value in rows:
  236. ws.append([label, value])
  237. if rule_config:
  238. ws.append([])
  239. ws.append(["规则配置", "值"])
  240. for key in (
  241. "self_stop_min_age",
  242. "self_up_min_age",
  243. "self_min_avg_uv",
  244. "partner_min_avg_uv",
  245. "min_daily_cost",
  246. "stop_quantile",
  247. "up_quantile",
  248. "scale_ratio",
  249. "scale_cooldown_days",
  250. "max_base_ratio",
  251. ):
  252. ws.append([key, rule_config.get(key)])
  253. fission_multiplier = rule_config.get("fission_multiplier")
  254. if isinstance(fission_multiplier, Mapping):
  255. ws.append([])
  256. ws.append(["传播裂变参数", "值"])
  257. for key in (
  258. "version",
  259. "cohort_date",
  260. "observation_end_date",
  261. "horizon_days",
  262. "miniapp_exact_available_rows",
  263. "gzh_exact_available_rows",
  264. "miniapp_channel_multiplier",
  265. "gzh_channel_multiplier",
  266. ):
  267. ws.append([key, fission_multiplier.get(key)])
  268. ws.append([])
  269. display_thresholds = thresholds.rename(
  270. columns={"t_stop": "关停线", "t_up": "扩量线"}
  271. )
  272. threshold_columns = [
  273. "统计窗口",
  274. "关停线",
  275. "扩量线",
  276. "阈值样本数",
  277. "小程序样本数",
  278. "公众号样本数",
  279. "企微参考实体数",
  280. ]
  281. ws.append(threshold_columns)
  282. for values in display_thresholds[threshold_columns].itertuples(
  283. index=False,
  284. name=None,
  285. ):
  286. ws.append(list(values))
  287. ws.append([])
  288. ws.append(["渠道", "建议动作数"])
  289. for entity_type, sheet_name in ENTITY_TO_SHEET.items():
  290. count = int(
  291. (
  292. candidates["entity_type"].eq(entity_type)
  293. & candidates["动作"].ne("")
  294. ).sum()
  295. )
  296. ws.append([sheet_name, count])
  297. ws.column_dimensions["A"].width = 22
  298. ws.column_dimensions["B"].width = 92
  299. for row in ws.iter_rows():
  300. if row[0].value in {"统计窗口", "渠道"}:
  301. for cell in row:
  302. cell.font = Font(bold=True)
  303. ws.freeze_panes = "A2"
  304. def write_workbook(
  305. candidates: pd.DataFrame,
  306. thresholds: pd.DataFrame,
  307. expected_dates: Sequence[str],
  308. output_path: Path,
  309. rule_config: Mapping[str, object] | None = None,
  310. fission_match_summary: pd.DataFrame | None = None,
  311. ) -> Path:
  312. output_path.parent.mkdir(parents=True, exist_ok=True)
  313. workbook = Workbook()
  314. workbook.remove(workbook.active)
  315. for sheet_name in BASE_COLUMNS:
  316. ws = workbook.create_sheet(sheet_name)
  317. frame = _sheet_frame(candidates, sheet_name)
  318. _write_dataframe(ws, frame)
  319. _format_sheet(ws, sheet_name)
  320. if fission_match_summary is not None:
  321. ws = workbook.create_sheet("传播裂变系数匹配")
  322. _write_dataframe(ws, fission_match_summary)
  323. ws.freeze_panes = "A2"
  324. ws.auto_filter.ref = ws.dimensions
  325. for cell in ws[1]:
  326. cell.fill = HEADER_FILL
  327. cell.font = HEADER_FONT
  328. for column_index in range(1, ws.max_column + 1):
  329. header = str(ws.cell(1, column_index).value or "")
  330. values = [
  331. str(ws.cell(row, column_index).value or "")
  332. for row in range(1, ws.max_row + 1)
  333. ]
  334. width = min(max(max(map(len, values)), len(header)) + 2, 42)
  335. ws.column_dimensions[get_column_letter(column_index)].width = max(
  336. width, 11
  337. )
  338. if header == "匹配率":
  339. for row in range(2, ws.max_row + 1):
  340. ws.cell(row, column_index).number_format = "0.00%"
  341. _write_summary(
  342. workbook,
  343. thresholds,
  344. candidates,
  345. expected_dates,
  346. rule_config,
  347. )
  348. workbook.save(output_path)
  349. return output_path