run_behavior_features.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. #!/usr/bin/env python3
  2. """Execute V2 feature SQL modules and produce a Feishu-shaped user detail CSV."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import sys
  7. from pathlib import Path
  8. import pandas as pd
  9. PROJECT_DIR = Path(__file__).resolve().parents[1]
  10. WORKSPACE_ROOT = Path(__file__).resolve().parents[2]
  11. if str(WORKSPACE_ROOT) not in sys.path:
  12. sys.path.insert(0, str(WORKSPACE_ROOT))
  13. if str(PROJECT_DIR) not in sys.path:
  14. sys.path.insert(0, str(PROJECT_DIR))
  15. from odps_module import ODPSClient # noqa: E402
  16. from src.feature_sql import ( # noqa: E402
  17. build_attributes_sql,
  18. build_background_return_sql,
  19. build_capture_sql,
  20. build_core_sql,
  21. build_input_users_sql,
  22. build_landing_return_sql,
  23. build_return_sql,
  24. )
  25. from src.user_input import load_users as load_user_input # noqa: E402
  26. BASE_DIR = PROJECT_DIR
  27. DEFAULT_INPUT = BASE_DIR / "samples" / "v2_220_20260810.csv"
  28. OUTPUT_COLUMNS = [
  29. "用户分类", "子分类", "统计周期", "用户id", "操作系统", "机型", "机型数",
  30. "地域", "地域数", "活跃天数", "点击卡片去重次数", "点击卡片不去重次数",
  31. "有效播放次数(进度30%或播放时长大于20秒)", "分享次数",
  32. "过去180天分享带回去重回流人数",
  33. "点击广告次数", "长按扫码次数", "广告播放中截图次数",
  34. "广告播放中切后台再返回次数", "广告落地页截图次数",
  35. "无扫码情况下落地页切换后台再返回", "来源个人分享(场景值1007)",
  36. "来源群分享(场景值1008)", "来源公众号文章(场景值1058)",
  37. "来源公众号即转(场景值1074)", "来源小程序投流(1067+1095)",
  38. "来源其他场景值", "来源上游用户shareid", "来源群id",
  39. ]
  40. RENAME = {
  41. "user_type": "用户分类",
  42. "sub_category": "子分类",
  43. "mid": "用户id",
  44. "operating_system_list": "操作系统",
  45. "device_model_list": "机型",
  46. "device_model_cnt": "机型数",
  47. "city_list": "地域",
  48. "city_cnt": "地域数",
  49. "active_day_cnt": "活跃天数",
  50. "card_click_object_uv": "点击卡片去重次数",
  51. "card_click_object_pv": "点击卡片不去重次数",
  52. "real_play_cnt": "有效播放次数(进度30%或播放时长大于20秒)",
  53. "video_share_cnt": "分享次数",
  54. "return_people_cnt": "过去180天分享带回去重回流人数",
  55. "own_ad_click_cnt": "点击广告次数",
  56. "long_press_scan_cnt": "长按扫码次数",
  57. "ad_play_capture_cnt": "广告播放中截图次数",
  58. "ad_play_background_return_cnt": "广告播放中切后台再返回次数",
  59. "ad_landing_capture_cnt": "广告落地页截图次数",
  60. "landing_no_scan_background_return_cnt": "无扫码情况下落地页切换后台再返回",
  61. "source_1007_cnt": "来源个人分享(场景值1007)",
  62. "source_1008_cnt": "来源群分享(场景值1008)",
  63. "source_1058_cnt": "来源公众号文章(场景值1058)",
  64. "source_1074_cnt": "来源公众号即转(场景值1074)",
  65. "source_1067_1095_cnt": "来源小程序投流(1067+1095)",
  66. "other_scene_list": "来源其他场景值",
  67. "source_shareid_list": "来源上游用户shareid",
  68. "source_opengid_list": "来源群id",
  69. }
  70. def load_users(path: Path, sample_per_group: int, lookback_days: int) -> pd.DataFrame:
  71. source = load_user_input(path, lookback_days=lookback_days)
  72. if sample_per_group > 0:
  73. source = (
  74. source.sort_values("mid", kind="stable")
  75. .groupby(["user_type", "sub_category"], as_index=False, group_keys=False)
  76. .head(sample_per_group)
  77. .reset_index(drop=True)
  78. )
  79. return source
  80. def latest_partition(client: ODPSClient, table_name: str) -> str:
  81. table = client.odps.get_table(table_name)
  82. values = [str(part.partition_spec["dt"]) for part in table.partitions]
  83. if not values:
  84. raise ValueError(f"{table_name}没有dt分区")
  85. return max(values)
  86. def build_queries(users: pd.DataFrame) -> dict[str, str]:
  87. records = users.to_dict("records")
  88. input_sql = build_input_users_sql(records)
  89. global_start = users["window_start_dt"].min()
  90. global_end = users["window_end_dt"].max()
  91. return {
  92. "core": build_core_sql(input_sql, global_start, global_end),
  93. "return": build_return_sql(
  94. input_sql,
  95. global_start,
  96. global_end,
  97. ),
  98. "attributes": build_attributes_sql(input_sql, global_start, global_end),
  99. "capture": build_capture_sql(input_sql, global_start, global_end),
  100. "background_return": build_background_return_sql(input_sql, global_start, global_end),
  101. "landing_return": build_landing_return_sql(input_sql, global_start, global_end),
  102. }
  103. def submit_all(client: ODPSClient, queries: dict[str, str]) -> tuple[dict[str, object], list[dict[str, str]]]:
  104. instances = {}
  105. metadata = []
  106. for name, sql in queries.items():
  107. instance = client.odps.run_sql(sql)
  108. instances[name] = instance
  109. item = {"name": name, "instance_id": instance.id, "logview": instance.get_logview_address()}
  110. metadata.append(item)
  111. print(f"[{name}] instance={item['instance_id']}")
  112. print(f"[{name}] logview={item['logview']}")
  113. return instances, metadata
  114. def read_all(instances: dict[str, object], raw_dir: Path) -> dict[str, pd.DataFrame]:
  115. results = {}
  116. for name, instance in instances.items():
  117. instance.wait_for_success()
  118. with instance.open_reader(tunnel=True) as reader:
  119. frame = reader.to_pandas()
  120. frame.columns = [str(column).lower() for column in frame.columns]
  121. frame.to_csv(raw_dir / f"{name}.csv", index=False)
  122. results[name] = frame
  123. print(f"[{name}] rows={len(frame)}")
  124. return results
  125. def merge_results(results: dict[str, pd.DataFrame], expected_users: int) -> pd.DataFrame:
  126. merged = results["core"]
  127. for name in ("return", "attributes", "capture", "background_return", "landing_return"):
  128. if results[name]["mid"].duplicated().any():
  129. raise AssertionError(f"{name}结果存在重复MID")
  130. if name == "return" and "return_people_cnt" in merged:
  131. merged = merged.drop(columns=["return_people_cnt"])
  132. merged = merged.merge(results[name], on="mid", how="left", validate="one_to_one")
  133. if len(merged) != expected_users or merged["mid"].nunique() != expected_users:
  134. raise AssertionError("合并后人数与输入不一致")
  135. merged["统计周期"] = (
  136. merged["window_start_dt"].astype(str)
  137. + "-"
  138. + merged["window_end_dt"].astype(str)
  139. )
  140. merged = merged.rename(columns=RENAME)
  141. for column in OUTPUT_COLUMNS:
  142. if column not in merged:
  143. merged[column] = 0
  144. text_columns = {
  145. "用户分类", "子分类", "统计周期", "用户id", "操作系统", "机型", "地域",
  146. "来源其他场景值", "来源上游用户shareid", "来源群id",
  147. }
  148. for column in OUTPUT_COLUMNS:
  149. if column in text_columns:
  150. merged[column] = merged[column].fillna("")
  151. else:
  152. merged[column] = pd.to_numeric(merged[column], errors="coerce").fillna(0).astype("int64")
  153. return merged[OUTPUT_COLUMNS].sort_values(["用户分类", "子分类", "用户id"], kind="stable")
  154. def main() -> None:
  155. parser = argparse.ArgumentParser(description=__doc__)
  156. parser.add_argument("--input", type=Path, default=DEFAULT_INPUT)
  157. parser.add_argument("--sample-per-group", type=int, default=0)
  158. parser.add_argument("--lookback-days", type=int, default=180)
  159. parser.add_argument("--sql-only", action="store_true")
  160. parser.add_argument("--run-name", default="full_220")
  161. parser.add_argument(
  162. "--modules",
  163. default="",
  164. help="Comma-separated modules to execute; missing module results are loaded from raw CSV files.",
  165. )
  166. parser.add_argument("--merge-only", action="store_true")
  167. parser.add_argument("--no-merge", action="store_true", help="Execute selected modules and save raw results only.")
  168. parser.add_argument(
  169. "--collect-existing",
  170. action="store_true",
  171. help="Collect results from the latest saved Instance ID for each selected module without resubmitting SQL.",
  172. )
  173. args = parser.parse_args()
  174. users = load_users(args.input, args.sample_per_group, args.lookback_days)
  175. output_dir = BASE_DIR / "output" / args.run_name / "behavior"
  176. sql_dir = BASE_DIR / "sql" / args.run_name / "behavior"
  177. raw_dir = output_dir / "raw"
  178. sql_dir.mkdir(parents=True, exist_ok=True)
  179. raw_dir.mkdir(parents=True, exist_ok=True)
  180. queries = build_queries(users)
  181. for name, sql in queries.items():
  182. (sql_dir / f"{name}.sql").write_text(sql + "\n", encoding="utf-8")
  183. users.to_csv(output_dir / "input_users.csv", index=False)
  184. if args.sql_only:
  185. print(f"rendered_sql={sql_dir}")
  186. return
  187. client = ODPSClient(project="loghubods")
  188. selected = [item.strip() for item in args.modules.split(",") if item.strip()]
  189. unknown = sorted(set(selected) - set(queries))
  190. if unknown:
  191. raise ValueError("未知查询模块: " + ", ".join(unknown))
  192. selected_queries = {name: queries[name] for name in selected} if selected else queries
  193. if args.merge_only:
  194. results = {}
  195. elif args.collect_existing:
  196. metadata_path = output_dir / "odps_runs.json"
  197. if not metadata_path.exists():
  198. raise ValueError(f"缺少ODPS执行记录: {metadata_path}")
  199. history = json.loads(metadata_path.read_text(encoding="utf-8"))
  200. latest_runs = {}
  201. for item in history:
  202. latest_runs[item["name"]] = item
  203. missing_runs = [name for name in selected_queries if name not in latest_runs]
  204. if missing_runs:
  205. raise ValueError("缺少模块Instance ID: " + ", ".join(missing_runs))
  206. instances = {
  207. name: client.odps.get_instance(latest_runs[name]["instance_id"])
  208. for name in selected_queries
  209. }
  210. results = read_all(instances, raw_dir)
  211. else:
  212. instances, metadata = submit_all(client, selected_queries)
  213. metadata_path = output_dir / "odps_runs.json"
  214. history = []
  215. if metadata_path.exists():
  216. history = json.loads(metadata_path.read_text(encoding="utf-8"))
  217. history.extend(metadata)
  218. metadata_path.write_text(
  219. json.dumps(history, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
  220. )
  221. results = read_all(instances, raw_dir)
  222. if args.no_merge:
  223. print(f"raw_results={raw_dir}")
  224. return
  225. for name in queries:
  226. if name in results:
  227. continue
  228. path = raw_dir / f"{name}.csv"
  229. if not path.exists():
  230. raise ValueError(f"缺少模块结果: {path}")
  231. frame = pd.read_csv(path)
  232. frame.columns = [str(column).lower() for column in frame.columns]
  233. results[name] = frame
  234. detail = merge_results(results, len(users))
  235. input_order = {mid: index for index, mid in enumerate(users["mid"])}
  236. detail["_input_order"] = detail["用户id"].map(input_order)
  237. detail = detail.sort_values("_input_order", kind="stable").drop(columns="_input_order")
  238. detail_path = output_dir / "user_detail.csv"
  239. detail.to_csv(detail_path, index=False)
  240. validation = detail.groupby(["用户分类", "子分类"], as_index=False).agg(用户数=("用户id", "nunique"))
  241. validation.to_csv(output_dir / "validation.csv", index=False)
  242. print(validation.to_string(index=False))
  243. print(f"detail={detail_path}")
  244. if __name__ == "__main__":
  245. main()