tree_weight.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. """
  2. 类目树节点热度计算。
  3. 六维(ext_pop / plat_sust_pop / plat_ly_pop / recent_pop / real_rov_7d / real_vov_7d)各自独立;
  4. 后两维存 rov_diff / vov_diff(相对全局基线):
  5. 1. 挂载点:将需求词级 avg/count 聚合为挂载点 avg/count
  6. 2. 树上任意节点:取其子树内全部「有数据」挂载点,
  7. score = sum(avg * count) / sum(count),并保留 avg 与 count 供上层聚合
  8. """
  9. from __future__ import annotations
  10. import logging
  11. from collections import defaultdict
  12. from dataclasses import dataclass, field
  13. from decimal import Decimal
  14. from types import SimpleNamespace
  15. from typing import Any
  16. from supply_infra.db.repositories.category_tree_weight_repo import (
  17. CategoryTreeWeightRepository,
  18. )
  19. from supply_infra.db.repositories.demand_belong_category_repo import (
  20. DemandBelongCategoryRepository,
  21. )
  22. from supply_infra.db.repositories.demand_popularity_stats_repo import (
  23. DemandPopularityStatsRepository,
  24. )
  25. from supply_infra.db.repositories.global_tree_category_repo import (
  26. GlobalTreeCategoryRepository,
  27. )
  28. from supply_infra.scoring.ranking import rank_to_scores
  29. from supply_infra.db.session import get_session
  30. logger = logging.getLogger(__name__)
  31. METRIC_KEYS: tuple[str, ...] = (
  32. "ext_pop",
  33. "plat_sust_pop",
  34. "plat_ly_pop",
  35. "recent_pop",
  36. "real_rov_7d",
  37. "real_vov_7d",
  38. )
  39. POP_DIM_KEYS: tuple[str, ...] = (
  40. "ext_pop",
  41. "plat_sust_pop",
  42. "plat_ly_pop",
  43. "recent_pop",
  44. )
  45. _UPSERT_BATCH = 500
  46. _RANK_UPDATE_BATCH = 500
  47. @dataclass
  48. class DimStats:
  49. avg: float = 0.0
  50. count: int = 0
  51. @dataclass
  52. class HangFeatures:
  53. """挂载点(有需求词挂在该树上的节点)聚合特征。"""
  54. category_id: int
  55. word_count: int = 0
  56. dims: dict[str, DimStats] = field(default_factory=dict)
  57. def _dec(value: float, places: int = 8) -> Decimal:
  58. return Decimal(str(round(float(value), places)))
  59. def _optional_dec(value: float | None, places: int = 8) -> Decimal | None:
  60. if value is None:
  61. return None
  62. return _dec(value, places)
  63. def _normalize_parent_id(parent_id: int | None) -> int | None:
  64. if parent_id is None or parent_id == 0:
  65. return None
  66. return parent_id
  67. def _build_tree_index(
  68. categories: list[tuple[int, int | None]],
  69. ) -> tuple[set[int], dict[int | None, list[int]], dict[int, int | None]]:
  70. """categories: (category_id, parent_id) 列表。"""
  71. by_id: set[int] = set()
  72. children: dict[int | None, list[int]] = defaultdict(list)
  73. parent_of: dict[int, int | None] = {}
  74. for cid, parent_id in categories:
  75. by_id.add(cid)
  76. pid = _normalize_parent_id(parent_id)
  77. parent_of[cid] = pid
  78. children[pid].append(cid)
  79. for kids in children.values():
  80. kids.sort()
  81. return by_id, children, parent_of
  82. def _aggregate_hang_features(
  83. belong_rows: list[tuple[int, int]],
  84. stats_by_belong_id: dict[int, Any],
  85. ) -> dict[int, HangFeatures]:
  86. """将词级 avg/count 按挂载类目聚合为挂载点 avg/count。"""
  87. acc: dict[int, dict[str, dict[str, float]]] = defaultdict(
  88. lambda: {dim: {"wsum": 0.0, "nsum": 0.0} for dim in METRIC_KEYS}
  89. )
  90. word_counts: dict[int, int] = defaultdict(int)
  91. for belong_id, category_id in belong_rows:
  92. word_counts[category_id] += 1
  93. stats = stats_by_belong_id.get(belong_id)
  94. if stats is None:
  95. continue
  96. for dim in METRIC_KEYS:
  97. n = int(getattr(stats, f"{dim}_count") or 0)
  98. if n <= 0:
  99. continue
  100. avg_raw = getattr(stats, f"{dim}_avg", None)
  101. if avg_raw is None:
  102. continue
  103. avg = float(avg_raw)
  104. cell = acc[category_id][dim]
  105. cell["wsum"] += avg * n
  106. cell["nsum"] += n
  107. features: dict[int, HangFeatures] = {}
  108. for category_id, wc in word_counts.items():
  109. dims: dict[str, DimStats] = {}
  110. for dim in METRIC_KEYS:
  111. cell = acc[category_id][dim]
  112. n = int(cell["nsum"])
  113. if n > 0:
  114. dims[dim] = DimStats(avg=cell["wsum"] / cell["nsum"], count=n)
  115. else:
  116. dims[dim] = DimStats()
  117. features[category_id] = HangFeatures(
  118. category_id=category_id,
  119. word_count=wc,
  120. dims=dims,
  121. )
  122. return features
  123. def _subtree_hanging_ids(
  124. node_id: int,
  125. children: dict[int | None, list[int]],
  126. hang_ids: set[int],
  127. cache: dict[int, list[int]],
  128. ) -> list[int]:
  129. if node_id in cache:
  130. return cache[node_id]
  131. ids: list[int] = []
  132. if node_id in hang_ids:
  133. ids.append(node_id)
  134. for child in children.get(node_id, []):
  135. ids.extend(_subtree_hanging_ids(child, children, hang_ids, cache))
  136. cache[node_id] = ids
  137. return ids
  138. def _rollup_avg_count(
  139. by_id: set[int],
  140. children: dict[int | None, list[int]],
  141. hangs: dict[int, HangFeatures],
  142. ) -> dict[int, dict[str, DimStats]]:
  143. """
  144. 每个节点取其子树内全部有数据挂载点:
  145. avg = sum(avg * count) / sum(count),count = sum(count)。
  146. """
  147. hang_ids = set(hangs)
  148. subtree_cache: dict[int, list[int]] = {}
  149. result: dict[int, dict[str, DimStats]] = {}
  150. for nid in by_id:
  151. hanging = _subtree_hanging_ids(nid, children, hang_ids, subtree_cache)
  152. dim_stats: dict[str, DimStats] = {}
  153. for dim in METRIC_KEYS:
  154. wsum = 0.0
  155. nsum = 0
  156. for hid in hanging:
  157. ds = hangs[hid].dims[dim]
  158. if ds.count <= 0:
  159. continue
  160. wsum += ds.avg * ds.count
  161. nsum += ds.count
  162. if nsum > 0:
  163. dim_stats[dim] = DimStats(avg=wsum / nsum, count=nsum)
  164. else:
  165. dim_stats[dim] = DimStats()
  166. result[nid] = dim_stats
  167. return result
  168. def _row_from_state(
  169. nid: int,
  170. biz_dt: str,
  171. hung_word_count: int,
  172. dim_stats: dict[str, DimStats],
  173. ) -> dict[str, Any]:
  174. row: dict[str, Any] = {
  175. "category_id": nid,
  176. "biz_dt": biz_dt,
  177. "hung_word_count": hung_word_count,
  178. }
  179. for dim in METRIC_KEYS:
  180. ds = dim_stats[dim]
  181. row[f"{dim}_count"] = int(ds.count)
  182. row[f"{dim}_avg"] = _optional_dec(ds.avg) if ds.count > 0 else None
  183. return row
  184. def _materialize_stats_row(stats: Any) -> SimpleNamespace:
  185. """在 session 内抽出标量,避免 DetachedInstanceError。"""
  186. payload: dict[str, Any] = {"demand_category_id": int(stats.demand_category_id)}
  187. for dim in METRIC_KEYS:
  188. count = int(getattr(stats, f"{dim}_count") or 0)
  189. payload[f"{dim}_count"] = count
  190. if count > 0:
  191. avg_raw = getattr(stats, f"{dim}_avg", None)
  192. payload[f"{dim}_avg"] = float(avg_raw) if avg_raw is not None else None
  193. else:
  194. payload[f"{dim}_avg"] = None
  195. return SimpleNamespace(**payload)
  196. def _materialize_weight_row(row: Any) -> dict[str, Any]:
  197. """在 session 内抽出标量,避免 DetachedInstanceError。"""
  198. payload: dict[str, Any] = {
  199. "category_id": int(row.category_id),
  200. "biz_dt": str(row.biz_dt),
  201. }
  202. for dim in POP_DIM_KEYS:
  203. count = int(getattr(row, f"{dim}_count", 0) or 0)
  204. payload[f"{dim}_count"] = count
  205. if count > 0:
  206. avg_raw = getattr(row, f"{dim}_avg", None)
  207. payload[f"{dim}_avg"] = float(avg_raw) if avg_raw is not None else None
  208. else:
  209. payload[f"{dim}_avg"] = None
  210. return payload
  211. def _build_rank_score_rows(
  212. rows: list[dict[str, Any]],
  213. ) -> list[dict[str, Any]]:
  214. """根据已写入的权重行计算各节点排名分。"""
  215. dim_scores: dict[str, dict[int, float]] = {}
  216. for dim in POP_DIM_KEYS:
  217. candidates: list[tuple[int, float]] = []
  218. for row in rows:
  219. count = int(row[f"{dim}_count"])
  220. if count <= 0:
  221. continue
  222. avg_raw = row[f"{dim}_avg"]
  223. if avg_raw is None:
  224. continue
  225. avg = float(avg_raw)
  226. candidates.append((int(row["category_id"]), avg))
  227. dim_scores[dim] = rank_to_scores(candidates)
  228. updates: list[dict[str, Any]] = []
  229. for row in rows:
  230. category_id = int(row["category_id"])
  231. biz_dt = str(row["biz_dt"])
  232. score_values = {
  233. f"{dim}_score": dim_scores[dim].get(category_id)
  234. for dim in POP_DIM_KEYS
  235. }
  236. non_null_scores = [v for v in score_values.values() if v is not None]
  237. total = sum(non_null_scores) if non_null_scores else None
  238. updates.append(
  239. {
  240. "category_id": category_id,
  241. "biz_dt": biz_dt,
  242. **{
  243. col: _optional_dec(score_values[col])
  244. for col in (
  245. "ext_pop_score",
  246. "plat_sust_pop_score",
  247. "plat_ly_pop_score",
  248. "recent_pop_score",
  249. )
  250. },
  251. "total_score": _optional_dec(total),
  252. }
  253. )
  254. return updates
  255. def _update_category_tree_rank_scores(biz_dt: str) -> dict[str, Any]:
  256. """在 category_tree_weight 全部写入后,按 biz_dt 更新四维排名分与 total_score。"""
  257. with get_session() as session:
  258. repo = CategoryTreeWeightRepository(session)
  259. rows = [
  260. _materialize_weight_row(row)
  261. for row in repo.list_by_biz_dt(biz_dt)
  262. ]
  263. if not rows:
  264. logger.info("Category tree rank scores: no rows, skip biz_dt=%s", biz_dt)
  265. return {"biz_dt": biz_dt, "nodes": 0, "updated": 0}
  266. updates = _build_rank_score_rows(rows)
  267. updated = 0
  268. with get_session() as session:
  269. repo = CategoryTreeWeightRepository(session)
  270. for i in range(0, len(updates), _RANK_UPDATE_BATCH):
  271. updated += repo.update_rank_scores(updates[i : i + _RANK_UPDATE_BATCH])
  272. result = {
  273. "biz_dt": biz_dt,
  274. "nodes": len(updates),
  275. "updated": updated,
  276. }
  277. logger.info("Category tree rank scores completed: %s", result)
  278. return result
  279. def compute_category_tree_weight(biz_dt: str) -> dict[str, Any]:
  280. """按 biz_dt 计算整棵树各维度加权平均分并写入 category_tree_weight。"""
  281. with get_session() as session:
  282. categories = [
  283. (int(c.id), c.parent_id)
  284. for c in GlobalTreeCategoryRepository(session).list_active_categories()
  285. ]
  286. belong_rows_raw = [
  287. (int(b.id), int(b.category_id))
  288. for b in DemandBelongCategoryRepository(session).list_active()
  289. if b.category_id is not None
  290. ]
  291. stats_by_belong = {
  292. int(s.demand_category_id): _materialize_stats_row(s)
  293. for s in DemandPopularityStatsRepository(session).list_by_biz_dt(biz_dt)
  294. }
  295. if not categories:
  296. logger.info("Category tree weight: no categories, skip biz_dt=%s", biz_dt)
  297. return {"biz_dt": biz_dt, "nodes": 0, "hanging": 0, "upserted": 0}
  298. by_id, children, _parent_of = _build_tree_index(categories)
  299. belong_rows = [
  300. (belong_id, category_id)
  301. for belong_id, category_id in belong_rows_raw
  302. if category_id in by_id
  303. ]
  304. hangs = _aggregate_hang_features(belong_rows, stats_by_belong)
  305. rolled = _rollup_avg_count(by_id, children, hangs)
  306. rows: list[dict[str, Any]] = []
  307. for nid in by_id:
  308. feat = hangs.get(nid)
  309. rows.append(
  310. _row_from_state(
  311. nid,
  312. biz_dt,
  313. feat.word_count if feat else 0,
  314. rolled[nid],
  315. )
  316. )
  317. upserted = 0
  318. with get_session() as session:
  319. repo = CategoryTreeWeightRepository(session)
  320. for i in range(0, len(rows), _UPSERT_BATCH):
  321. upserted += repo.upsert_rows(rows[i : i + _UPSERT_BATCH])
  322. result = {
  323. "biz_dt": biz_dt,
  324. "nodes": len(rows),
  325. "hanging": len(hangs),
  326. "upserted": upserted,
  327. }
  328. logger.info("Category tree weight completed: %s", result)
  329. rank_stats = _update_category_tree_rank_scores(biz_dt)
  330. result["rank_scores"] = rank_stats
  331. return result